mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-15 02:39:35 +00:00
Compare commits
25 Commits
v0.9.4-rc5
...
v0.9.5-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b6a814b7 | ||
|
|
e415420150 | ||
|
|
e6e039e0f2 | ||
|
|
657e2d4a49 | ||
|
|
337c1cded0 | ||
|
|
444abcb036 | ||
|
|
c82a835e70 | ||
|
|
3c185d4bd2 | ||
|
|
ba2049b705 | ||
|
|
7691af30de | ||
|
|
ab83b21979 | ||
|
|
f18b62e165 | ||
|
|
f98bf2a1f8 | ||
|
|
8c67c05d87 | ||
|
|
3f11ee58a7 | ||
|
|
c8d94026ff | ||
|
|
5be6c54339 | ||
|
|
259dedcded | ||
|
|
b70fea69cc | ||
|
|
2bea7e83e1 | ||
|
|
812076d4fe | ||
|
|
b0b74871e7 | ||
|
|
29708a1f7c | ||
|
|
e686a11808 | ||
|
|
25f58d2e43 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
|||||||
logs
|
logs
|
||||||
*.log
|
*.log
|
||||||
|
|
||||||
|
.claude/
|
||||||
|
CLAUDE.md
|
||||||
|
|
||||||
# Runtime data
|
# Runtime data
|
||||||
pids
|
pids
|
||||||
*.pid
|
*.pid
|
||||||
|
|||||||
@@ -163,5 +163,16 @@
|
|||||||
"wird sich bei Ihnen melden",
|
"wird sich bei Ihnen melden",
|
||||||
"ich melde mich bei dir",
|
"ich melde mich bei dir",
|
||||||
"wir können nicht"
|
"wir können nicht"
|
||||||
|
],
|
||||||
|
"it-IT": [
|
||||||
|
"segreteria telefonica",
|
||||||
|
"risponde la segreteria telefonica",
|
||||||
|
"lascia un messaggio",
|
||||||
|
"puoi lasciare un messaggio dopo il segnale",
|
||||||
|
"dopo il segnale acustico",
|
||||||
|
"il numero chiamato non è raggiungibile",
|
||||||
|
"non è raggiungibile",
|
||||||
|
"lascia pure un messaggio",
|
||||||
|
"puoi lasciare un messaggio"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,11 @@ const WsRequestor = require('../../utils/ws-requestor');
|
|||||||
const RootSpan = require('../../utils/call-tracer');
|
const RootSpan = require('../../utils/call-tracer');
|
||||||
const dbUtils = require('../../utils/db-utils');
|
const dbUtils = require('../../utils/db-utils');
|
||||||
const { decrypt } = require('../../utils/encrypt-decrypt');
|
const { decrypt } = require('../../utils/encrypt-decrypt');
|
||||||
const { mergeSdpMedia, extractSdpMedia } = require('../../utils/sdp-utils');
|
const { mergeSdpMedia, extractSdpMedia, removeVideoSdp } = require('../../utils/sdp-utils');
|
||||||
const { createCallSchema, customSanitizeFunction } = require('../schemas/create-call');
|
const { createCallSchema, customSanitizeFunction } = require('../schemas/create-call');
|
||||||
const { selectHostPort } = require('../../utils/network');
|
const { selectHostPort } = require('../../utils/network');
|
||||||
const { JAMBONES_DIAL_SBC_FOR_REGISTERED_USER } = require('../../config');
|
const { JAMBONES_DIAL_SBC_FOR_REGISTERED_USER } = require('../../config');
|
||||||
|
const { createMediaEndpoint } = require('../../utils/media-endpoint');
|
||||||
|
|
||||||
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
|
const removeNullProperties = (obj) => (Object.keys(obj).forEach((key) => obj[key] === null && delete obj[key]), obj);
|
||||||
const removeNulls = (req, res, next) => {
|
const removeNulls = (req, res, next) => {
|
||||||
@@ -67,7 +68,7 @@ router.post('/',
|
|||||||
const {
|
const {
|
||||||
lookupAppBySid
|
lookupAppBySid
|
||||||
} = srf.locals.dbHelpers;
|
} = srf.locals.dbHelpers;
|
||||||
const {getSBC, getFreeswitch} = srf.locals;
|
const {getSBC} = srf.locals;
|
||||||
let sbcAddress = getSBC();
|
let sbcAddress = getSBC();
|
||||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||||
const target = restDial.to;
|
const target = restDial.to;
|
||||||
@@ -170,9 +171,7 @@ router.post('/',
|
|||||||
}
|
}
|
||||||
|
|
||||||
/* create endpoint for outdial */
|
/* create endpoint for outdial */
|
||||||
const ms = getFreeswitch();
|
const ep = await createMediaEndpoint(srf, logger);
|
||||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
|
||||||
const ep = await ms.createEndpoint();
|
|
||||||
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
||||||
|
|
||||||
/* launch outdial */
|
/* launch outdial */
|
||||||
@@ -181,10 +180,14 @@ router.post('/',
|
|||||||
let localSdp = ep.local.sdp;
|
let localSdp = ep.local.sdp;
|
||||||
|
|
||||||
if (req.body.dual_streams) {
|
if (req.body.dual_streams) {
|
||||||
dualEp = await ms.createEndpoint();
|
dualEp = await createMediaEndpoint(srf, logger);
|
||||||
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
|
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
|
||||||
}
|
}
|
||||||
|
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS) {
|
||||||
|
logger.debug('createCall: removing video sdp');
|
||||||
|
localSdp = removeVideoSdp(localSdp);
|
||||||
|
ep.modify(localSdp);
|
||||||
|
}
|
||||||
const connectStream = async(remoteSdp) => {
|
const connectStream = async(remoteSdp) => {
|
||||||
if (remoteSdp !== sdp) {
|
if (remoteSdp !== sdp) {
|
||||||
sdp = remoteSdp;
|
sdp = remoteSdp;
|
||||||
@@ -235,6 +238,7 @@ router.post('/',
|
|||||||
if (app.call_hook.url === app.call_status_hook?.url || !app.call_status_hook?.url) {
|
if (app.call_hook.url === app.call_status_hook?.url || !app.call_status_hook?.url) {
|
||||||
logger.debug('reusing websocket for call status hook');
|
logger.debug('reusing websocket for call status hook');
|
||||||
app.notifier = app.requestor;
|
app.notifier = app.requestor;
|
||||||
|
app.call_status_hook = app.call_hook;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
|||||||
@@ -116,8 +116,8 @@ const customSanitizeFunction = (value) => {
|
|||||||
/* trims characters at the beginning and at the end of a string */
|
/* trims characters at the beginning and at the end of a string */
|
||||||
value = value.trim();
|
value = value.trim();
|
||||||
|
|
||||||
/* Verify strings including 'http' via new URL */
|
// Only attempt to parse if the whole string is a URL
|
||||||
if (value.includes('http')) {
|
if (/^https?:\/\/\S+$/.test(value)) {
|
||||||
value = new URL(value).toString();
|
value = new URL(value).toString();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -332,7 +332,7 @@ module.exports = function(srf, logger) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Resolve application.speech_synthesis_voice if it's custom voice
|
// Resolve application.speech_synthesis_voice if it's custom voice
|
||||||
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice.startsWith('custom_')) {
|
if (app2.speech_synthesis_vendor === 'google' && app2.speech_synthesis_voice?.startsWith('custom_')) {
|
||||||
const arr = /custom_(.*)/.exec(app2.speech_synthesis_voice);
|
const arr = /custom_(.*)/.exec(app2.speech_synthesis_voice);
|
||||||
if (arr) {
|
if (arr) {
|
||||||
const google_custom_voice_sid = arr[1];
|
const google_custom_voice_sid = arr[1];
|
||||||
|
|||||||
@@ -29,10 +29,6 @@ const {
|
|||||||
JAMBONES_INJECT_CONTENT,
|
JAMBONES_INJECT_CONTENT,
|
||||||
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
|
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
|
||||||
AWS_REGION,
|
AWS_REGION,
|
||||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
|
||||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
|
||||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
|
||||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
|
|
||||||
} = require('../config');
|
} = require('../config');
|
||||||
const bent = require('bent');
|
const bent = require('bent');
|
||||||
const BackgroundTaskManager = require('../utils/background-task-manager');
|
const BackgroundTaskManager = require('../utils/background-task-manager');
|
||||||
@@ -40,7 +36,7 @@ const dbUtils = require('../utils/db-utils');
|
|||||||
const BADPRECONDITIONS = 'preconditions not met';
|
const BADPRECONDITIONS = 'preconditions not met';
|
||||||
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
|
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
|
||||||
const { NonFatalTaskError} = require('../utils/error');
|
const { NonFatalTaskError} = require('../utils/error');
|
||||||
const { sleepFor } = require('../utils/helpers');
|
const { createMediaEndpoint } = require('../utils/media-endpoint');
|
||||||
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
|
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
|
||||||
WHERE webhook_sid =
|
WHERE webhook_sid =
|
||||||
(
|
(
|
||||||
@@ -220,6 +216,18 @@ class CallSession extends Emitter {
|
|||||||
this._synthesizer = synth;
|
this._synthesizer = synth;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Say stream enabled
|
||||||
|
*/
|
||||||
|
|
||||||
|
get autoStreamTts() {
|
||||||
|
return this._autoStreamTts || false;
|
||||||
|
}
|
||||||
|
|
||||||
|
set autoStreamTts(i) {
|
||||||
|
this._autoStreamTts = i;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* ASR TTS fallback
|
* ASR TTS fallback
|
||||||
*/
|
*/
|
||||||
@@ -1085,6 +1093,7 @@ class CallSession extends Emitter {
|
|||||||
return {
|
return {
|
||||||
api_key: credential.api_key,
|
api_key: credential.api_key,
|
||||||
model_id: credential.model_id,
|
model_id: credential.model_id,
|
||||||
|
stt_model_id: credential.stt_model_id,
|
||||||
embedding: credential.embedding,
|
embedding: credential.embedding,
|
||||||
options: credential.options
|
options: credential.options
|
||||||
};
|
};
|
||||||
@@ -1096,10 +1105,18 @@ class CallSession extends Emitter {
|
|||||||
options: credential.options
|
options: credential.options
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if ('inworld' === vendor) {
|
||||||
|
return {
|
||||||
|
api_key: credential.api_key,
|
||||||
|
model_id: credential.model_id,
|
||||||
|
options: credential.options
|
||||||
|
};
|
||||||
|
}
|
||||||
else if ('assemblyai' === vendor) {
|
else if ('assemblyai' === vendor) {
|
||||||
return {
|
return {
|
||||||
speech_credential_sid: credential.speech_credential_sid,
|
speech_credential_sid: credential.speech_credential_sid,
|
||||||
api_key: credential.api_key
|
api_key: credential.api_key,
|
||||||
|
service_version: credential.service_version
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if ('voxist' === vendor) {
|
else if ('voxist' === vendor) {
|
||||||
@@ -1409,6 +1426,14 @@ class CallSession extends Emitter {
|
|||||||
method: 'POST'
|
method: 'POST'
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* if given a relative url then send to same base url as current session */
|
||||||
|
if (this.requestor._isRelativeUrl(opts.call_hook?.url)) {
|
||||||
|
opts.call_hook.url = `${this.requestor.baseUrl}${opts.call_hook?.url}`;
|
||||||
|
}
|
||||||
|
if (this.requestor._isRelativeUrl(opts.call_status_hook?.url)) {
|
||||||
|
opts.call_status_hook.url = `${this.requestor.baseUrl}${opts.call_status_hook?.url}`;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -1799,6 +1824,10 @@ Duration=${duration} `
|
|||||||
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskStatus - Error sending'));
|
.catch((err) => this.logger.debug({err}, 'CallSession:_notifyTaskStatus - Error sending'));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _internalTtsStreamingBufferTokens(tokens) {
|
||||||
|
return await this.ttsStreamingBuffer?.bufferTokens(tokens) || {status: 'failed', reason: 'no tts streaming buffer'};
|
||||||
|
}
|
||||||
|
|
||||||
_lccTtsFlush(opts) {
|
_lccTtsFlush(opts) {
|
||||||
this.ttsStreamingBuffer?.flush(opts);
|
this.ttsStreamingBuffer?.flush(opts);
|
||||||
}
|
}
|
||||||
@@ -2285,8 +2314,7 @@ Duration=${duration} `
|
|||||||
|
|
||||||
// need to allocate an endpoint
|
// need to allocate an endpoint
|
||||||
try {
|
try {
|
||||||
if (!this.ms) this.ms = this.getMS();
|
const ep = await this._createMediaEndpoint({
|
||||||
const ep = await this.ms.createEndpoint({
|
|
||||||
headers: {
|
headers: {
|
||||||
'X-Jambones-Call-ID': this.callId,
|
'X-Jambones-Call-ID': this.callId,
|
||||||
},
|
},
|
||||||
@@ -2295,7 +2323,6 @@ Duration=${duration} `
|
|||||||
//ep.cs = this;
|
//ep.cs = this;
|
||||||
this.ep = ep;
|
this.ep = ep;
|
||||||
this.logger.info(`allocated endpoint ${ep.uuid}`);
|
this.logger.info(`allocated endpoint ${ep.uuid}`);
|
||||||
this._configMsEndpoint();
|
|
||||||
|
|
||||||
this.ep.on('destroy', () => {
|
this.ep.on('destroy', () => {
|
||||||
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
|
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
|
||||||
@@ -2366,9 +2393,6 @@ Duration=${duration} `
|
|||||||
this.logger.error('CallSession:replaceEndpoint cannot be called without stable dlg');
|
this.logger.error('CallSession:replaceEndpoint cannot be called without stable dlg');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// When this call kicked out from conference, session need to replace endpoint
|
|
||||||
// but this.ms might be undefined/null at this case.
|
|
||||||
this.ms = this.ms || this.getMS();
|
|
||||||
// Destroy previous ep if it's still running.
|
// Destroy previous ep if it's still running.
|
||||||
if (this.ep?.connected) this.ep.destroy();
|
if (this.ep?.connected) this.ep.destroy();
|
||||||
|
|
||||||
@@ -2393,8 +2417,7 @@ Duration=${duration} `
|
|||||||
* This prevents call failures during media renegotiation.
|
* This prevents call failures during media renegotiation.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
this.ep = await this.ms.createEndpoint();
|
this.ep = await this._createMediaEndpoint();
|
||||||
this._configMsEndpoint();
|
|
||||||
|
|
||||||
const sdp = await this.dlg.modify(this.ep.local.sdp);
|
const sdp = await this.dlg.modify(this.ep.local.sdp);
|
||||||
await this.ep.modify(sdp);
|
await this.ep.modify(sdp);
|
||||||
@@ -2654,15 +2677,8 @@ Duration=${duration} `
|
|||||||
async createOrRetrieveEpAndMs() {
|
async createOrRetrieveEpAndMs() {
|
||||||
if (this.ms && this.ep) return {ms: this.ms, ep: this.ep};
|
if (this.ms && this.ep) return {ms: this.ms, ep: this.ep};
|
||||||
|
|
||||||
// get a media server
|
|
||||||
if (!this.ms) {
|
|
||||||
const ms = this.srf.locals.getFreeswitch();
|
|
||||||
if (!ms) throw new Error('no available freeswitch');
|
|
||||||
this.ms = ms;
|
|
||||||
}
|
|
||||||
if (!this.ep) {
|
if (!this.ep) {
|
||||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
this.ep = await this._createMediaEndpoint({remoteSdp: this.req.body});
|
||||||
this._configMsEndpoint();
|
|
||||||
}
|
}
|
||||||
return {ms: this.ms, ep: this.ep};
|
return {ms: this.ms, ep: this.ep};
|
||||||
}
|
}
|
||||||
@@ -2815,8 +2831,7 @@ Duration=${duration} `
|
|||||||
async reAnchorMedia(currentMediaRoute = MediaPath.PartialMedia) {
|
async reAnchorMedia(currentMediaRoute = MediaPath.PartialMedia) {
|
||||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||||
|
|
||||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||||
this._configMsEndpoint();
|
|
||||||
await this.dlg.modify(this.ep.local.sdp, {
|
await this.dlg.modify(this.ep.local.sdp, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Reason': 'anchor-media'
|
'X-Reason': 'anchor-media'
|
||||||
@@ -2891,59 +2906,13 @@ Duration=${duration} `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_configMsEndpoint() {
|
|
||||||
this._enableInbandDtmfIfRequired(this.ep);
|
|
||||||
this.ep.once('destroy', this._handleMediaTimeout.bind(this));
|
|
||||||
const opts = {
|
|
||||||
...(this.onHoldMusic && {holdMusic: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}),
|
|
||||||
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
|
|
||||||
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
|
|
||||||
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
|
|
||||||
};
|
|
||||||
if (Object.keys(opts).length > 0) {
|
|
||||||
this.ep.set(opts);
|
|
||||||
}
|
|
||||||
|
|
||||||
const origDestroy = this.ep.destroy.bind(this.ep);
|
|
||||||
this.ep.destroy = async() => {
|
|
||||||
try {
|
|
||||||
if (this.currentTask?.name === TaskName.Transcribe && JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS) {
|
|
||||||
// transcribe task is being used, wait for some time before destroy
|
|
||||||
// if final transcription is received but endpoint is already closed,
|
|
||||||
// freeswitch module will not be able to send the transcription
|
|
||||||
|
|
||||||
this.logger.debug('callSession:_configMsEndpoint -' +
|
|
||||||
' transcribe task, wait for some time before destroy');
|
|
||||||
await sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS);
|
|
||||||
}
|
|
||||||
await origDestroy();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'callSession:_configMsEndpoint - error destroying endpoint');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async _handleMediaTimeout(evt) {
|
async _handleMediaTimeout(evt) {
|
||||||
if (evt.reason === 'MEDIA_TIMEOUT' && !this.callGone) {
|
if (evt?.reason === 'MEDIA_TIMEOUT' && !this.callGone) {
|
||||||
this.logger.info('CallSession:_handleMediaTimeout: received MEDIA_TIMEOUT, hangup the call');
|
this.logger.info('CallSession:_handleMediaTimeout: received MEDIA_TIMEOUT, hangup the call');
|
||||||
this._jambonzHangup('Media Timeout');
|
this._jambonzHangup('Media Timeout');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async _enableInbandDtmfIfRequired(ep) {
|
|
||||||
if (ep.inbandDtmfEnabled) return;
|
|
||||||
// only enable inband dtmf detection if voip carrier dtmf_type === tones
|
|
||||||
if (this.inbandDtmfEnabled) {
|
|
||||||
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
|
||||||
try {
|
|
||||||
ep.execute('start_dtmf');
|
|
||||||
ep.inbandDtmfEnabled = true;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'CallSession:_enableInbandDtmf - error enable inband DTMF');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* notifyTaskError - only used when websocket connection is used instead of webhooks
|
* notifyTaskError - only used when websocket connection is used instead of webhooks
|
||||||
*/
|
*/
|
||||||
@@ -2995,6 +2964,18 @@ Duration=${duration} `
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _enableInbandDtmfIfRequired(ep) {
|
||||||
|
if (ep.inbandDtmfEnabled) return;
|
||||||
|
// only enable inband dtmf detection if voip carrier dtmf_type === tones
|
||||||
|
if (this.inbandDtmfEnabled) {
|
||||||
|
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
||||||
|
ep.execute('start_dtmf').catch((err) => {
|
||||||
|
this.logger.info({err}, 'CallSession:_enableInbandDtmfIfRequired - error starting DTMF');
|
||||||
|
});
|
||||||
|
ep.inbandDtmfEnabled = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_clearTasks(backgroundGather, evt) {
|
_clearTasks(backgroundGather, evt) {
|
||||||
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
|
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
|
||||||
this.logger.debug({evt}, 'CallSession:_clearTasks on event from background gather');
|
this.logger.debug({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||||
@@ -3102,6 +3083,16 @@ Duration=${duration} `
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
|
||||||
|
return await createMediaEndpoint(this.srf, this.logger, {
|
||||||
|
activeMs: this.getMS(),
|
||||||
|
drachtioFsmrfOptions,
|
||||||
|
onHoldMusic: this.onHoldMusic,
|
||||||
|
inbandDtmfEnabled: this.inbandDtmfEnabled,
|
||||||
|
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
getFormattedConversation(numTurns) {
|
getFormattedConversation(numTurns) {
|
||||||
const turns = this.conversationTurns.slice(-numTurns);
|
const turns = this.conversationTurns.slice(-numTurns);
|
||||||
if (turns.length === 0) return null;
|
if (turns.length === 0) return null;
|
||||||
|
|||||||
@@ -27,10 +27,13 @@ class RestCallSession extends CallSession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||||
this._notifyCallStatusChange({
|
|
||||||
callStatus: CallStatus.Trying,
|
setImmediate(() => {
|
||||||
sipStatus: 100,
|
this._notifyCallStatusChange({
|
||||||
sipReason: 'Trying'
|
callStatus: CallStatus.Trying,
|
||||||
|
sipStatus: 100,
|
||||||
|
sipReason: 'Trying'
|
||||||
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,12 +45,11 @@ class SipRecCallSession extends InboundCallSession {
|
|||||||
|
|
||||||
async answerSipRecCall() {
|
async answerSipRecCall() {
|
||||||
try {
|
try {
|
||||||
this.ms = this.getMS();
|
|
||||||
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
let remoteSdp = this.sdp1.replace(/sendonly/, 'sendrecv');
|
||||||
this.ep = await this.ms.createEndpoint({remoteSdp});
|
this.ep = await this._createMediaEndpoint({remoteSdp});
|
||||||
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
//this.logger.debug({remoteSdp, localSdp: this.ep.local.sdp}, 'SipRecCallSession - allocated first endpoint');
|
||||||
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
remoteSdp = this.sdp2.replace(/sendonly/, 'sendrecv');
|
||||||
this.ep2 = await this.ms.createEndpoint({remoteSdp});
|
this.ep2 = await this._createMediaEndpoint({remoteSdp});
|
||||||
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
//this.logger.debug({remoteSdp, localSdp: this.ep2.local.sdp}, 'SipRecCallSession - allocated second endpoint');
|
||||||
await this.ep.bridge(this.ep2);
|
await this.ep.bridge(this.ep2);
|
||||||
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
const combinedSdp = await createSipRecPayload(this.ep.local.sdp, this.ep2.local.sdp, this.logger);
|
||||||
|
|||||||
31
lib/tasks/alert.js
Normal file
31
lib/tasks/alert.js
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
const Task = require('./task');
|
||||||
|
const {TaskName} = require('../utils/constants');
|
||||||
|
|
||||||
|
class TaskAlert extends Task {
|
||||||
|
constructor(logger, opts, parentTask) {
|
||||||
|
super(logger, opts);
|
||||||
|
this.message = this.data.message;
|
||||||
|
}
|
||||||
|
|
||||||
|
get name() { return TaskName.Alert; }
|
||||||
|
|
||||||
|
async exec(cs) {
|
||||||
|
const {srf, accountSid:account_sid, callSid:target_sid, applicationSid:application_sid} = cs;
|
||||||
|
const {writeAlerts, AlertType} = srf.locals;
|
||||||
|
await super.exec(cs);
|
||||||
|
writeAlerts({
|
||||||
|
account_sid,
|
||||||
|
alert_type: AlertType.APPLICATION,
|
||||||
|
detail: `Application SID ${application_sid}`,
|
||||||
|
message: this.message,
|
||||||
|
target_sid
|
||||||
|
}).catch((err) => this.logger.info({err}, 'Error generating alert application'));
|
||||||
|
}
|
||||||
|
|
||||||
|
async kill(cs) {
|
||||||
|
super.kill(cs);
|
||||||
|
this.notifyTaskDone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = TaskAlert;
|
||||||
@@ -17,7 +17,8 @@ class TaskConfig extends Task {
|
|||||||
'actionHookDelayAction',
|
'actionHookDelayAction',
|
||||||
'boostAudioSignal',
|
'boostAudioSignal',
|
||||||
'vad',
|
'vad',
|
||||||
'ttsStream'
|
'ttsStream',
|
||||||
|
'autoStreamTts'
|
||||||
].forEach((k) => this[k] = this.data[k] || {});
|
].forEach((k) => this[k] = this.data[k] || {});
|
||||||
|
|
||||||
if ('notifyEvents' in this.data) {
|
if ('notifyEvents' in this.data) {
|
||||||
@@ -117,6 +118,7 @@ class TaskConfig extends Task {
|
|||||||
if (this.hasTtsStream) {
|
if (this.hasTtsStream) {
|
||||||
phrase.push(`${this.ttsStream.enable ? 'enable' : 'disable'} ttsStream`);
|
phrase.push(`${this.ttsStream.enable ? 'enable' : 'disable'} ttsStream`);
|
||||||
}
|
}
|
||||||
|
if ('autoStreamTts' in this.data) phrase.push(`enable Say.stream value ${this.data.autoStreamTts ? 'on' : 'off'}`);
|
||||||
return `${this.name}{${phrase.join(',')}}`;
|
return `${this.name}{${phrase.join(',')}}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -296,6 +298,11 @@ class TaskConfig extends Task {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if ('autoStreamTts' in this.data) {
|
||||||
|
this.logger.info(`Config: autoStreamTts set to ${this.data.autoStreamTts}`);
|
||||||
|
cs.autoStreamTts = this.data.autoStreamTts;
|
||||||
|
}
|
||||||
|
|
||||||
if (this.hasFillerNoise) {
|
if (this.hasFillerNoise) {
|
||||||
const {enable, ...opts} = this.fillerNoise;
|
const {enable, ...opts} = this.fillerNoise;
|
||||||
this.logger.info({fillerNoise: this.fillerNoise}, 'Config: fillerNoise');
|
this.logger.info({fillerNoise: this.fillerNoise}, 'Config: fillerNoise');
|
||||||
@@ -330,7 +337,9 @@ class TaskConfig extends Task {
|
|||||||
};
|
};
|
||||||
this.logger.info({opts: this.gatherOpts}, 'Config: enabling ttsStream');
|
this.logger.info({opts: this.gatherOpts}, 'Config: enabling ttsStream');
|
||||||
cs.enableBackgroundTtsStream(this.sayOpts);
|
cs.enableBackgroundTtsStream(this.sayOpts);
|
||||||
} else if (!this.ttsStream.enable) {
|
}
|
||||||
|
// only disable ttsStream if it specifically set to false
|
||||||
|
else if (this.ttsStream.enable === false) {
|
||||||
this.logger.info('Config: disabling ttsStream');
|
this.logger.info('Config: disabling ttsStream');
|
||||||
cs.disableTtsStream();
|
cs.disableTtsStream();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -271,7 +271,12 @@ class TaskDial extends Task {
|
|||||||
}
|
}
|
||||||
this._removeDtmfDetection(cs.dlg);
|
this._removeDtmfDetection(cs.dlg);
|
||||||
this._removeDtmfDetection(this.dlg);
|
this._removeDtmfDetection(this.dlg);
|
||||||
await this._killOutdials();
|
try {
|
||||||
|
await this._killOutdials();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.logger.info({err}, 'Dial:kill - error killing outdials');
|
||||||
|
}
|
||||||
if (this.sd) {
|
if (this.sd) {
|
||||||
const byeReasonHeader = this.killReason === KillReason.MediaTimeout ? 'Media Timeout' : undefined;
|
const byeReasonHeader = this.killReason === KillReason.MediaTimeout ? 'Media Timeout' : undefined;
|
||||||
this.sd.kill(byeReasonHeader);
|
this.sd.kill(byeReasonHeader);
|
||||||
@@ -281,13 +286,22 @@ class TaskDial extends Task {
|
|||||||
}
|
}
|
||||||
if (this.callSid) sessionTracker.remove(this.callSid);
|
if (this.callSid) sessionTracker.remove(this.callSid);
|
||||||
if (this.listenTask) {
|
if (this.listenTask) {
|
||||||
await this.listenTask.kill(cs);
|
try {
|
||||||
this.listenTask.span.end();
|
await this.listenTask.kill(cs);
|
||||||
|
this.listenTask?.span?.end();
|
||||||
|
}
|
||||||
|
catch (err) {
|
||||||
|
this.logger.error({err}, 'Dial:kill - error killing listen task');
|
||||||
|
}
|
||||||
this.listenTask = null;
|
this.listenTask = null;
|
||||||
}
|
}
|
||||||
if (this.transcribeTask) {
|
if (this.transcribeTask) {
|
||||||
await this.transcribeTask.kill(cs);
|
try {
|
||||||
this.transcribeTask.span.end();
|
await this.transcribeTask.kill(cs);
|
||||||
|
this.transcribeTask?.span?.end();
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.error({err}, 'Dial:kill - error killing transcribe task');
|
||||||
|
}
|
||||||
this.transcribeTask = null;
|
this.transcribeTask = null;
|
||||||
}
|
}
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
@@ -383,7 +397,11 @@ class TaskDial extends Task {
|
|||||||
...customHeaders
|
...customHeaders
|
||||||
}
|
}
|
||||||
}, httpHeaders);
|
}, httpHeaders);
|
||||||
if (json && Array.isArray(json)) {
|
res.send(202);
|
||||||
|
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
||||||
|
|
||||||
|
const returnedInstructions = !!json && Array.isArray(json);
|
||||||
|
if (returnedInstructions) {
|
||||||
try {
|
try {
|
||||||
const logger = isChild ? this.logger : this.sd.logger;
|
const logger = isChild ? this.logger : this.sd.logger;
|
||||||
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
|
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||||
@@ -401,16 +419,21 @@ class TaskDial extends Task {
|
|||||||
/* need to update the callSid of the child with its own (new) AdultingCallSession */
|
/* need to update the callSid of the child with its own (new) AdultingCallSession */
|
||||||
sessionTracker.add(adultingSession.callSid, adultingSession);
|
sessionTracker.add(adultingSession.callSid, adultingSession);
|
||||||
}
|
}
|
||||||
|
if (this.ep) this.ep.unbridge();
|
||||||
|
|
||||||
|
/* if we got the REFER on the parent leg, end the dial task after completing the refer */
|
||||||
|
if (!isChild) {
|
||||||
|
logger.info('DialTask:handleRefer - killing dial task after processing REFER on parent leg');
|
||||||
|
cs.currentTask?.kill(cs, KillReason.ReferComplete);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, 'Dial:handleRefer - error setting new application after receiving REFER');
|
this.logger.info(err, 'Dial:handleRefer - error setting new application after receiving REFER');
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
//caller and callee legs are briged together, accept refer with 202 will release callee leg endpoint
|
else {
|
||||||
//that makes freeswitch release endpoint for caller leg.
|
this.logger.info('DialTask:handleRefer - no tasks returned from referHook, not setting new application');
|
||||||
if (this.ep) this.ep.unbridge();
|
}
|
||||||
res.send(202);
|
|
||||||
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
|
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
|
||||||
res.send(err.statusCode || 501);
|
res.send(err.statusCode || 501);
|
||||||
|
|||||||
@@ -83,7 +83,8 @@ class TaskDub extends TtsTask {
|
|||||||
action: 'playOnTrack',
|
action: 'playOnTrack',
|
||||||
track: this.track,
|
track: this.track,
|
||||||
play: this.play,
|
play: this.play,
|
||||||
loop: this.loop ? 'loop' : 'once',
|
// drachtio-fsmrf will convert loop from boolean to 'loop' or 'once'
|
||||||
|
loop: this.loop,
|
||||||
gain: this.gain
|
gain: this.gain
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ const {
|
|||||||
JambonzTranscriptionEvents,
|
JambonzTranscriptionEvents,
|
||||||
AssemblyAiTranscriptionEvents,
|
AssemblyAiTranscriptionEvents,
|
||||||
VoxistTranscriptionEvents,
|
VoxistTranscriptionEvents,
|
||||||
|
CartesiaTranscriptionEvents,
|
||||||
OpenAITranscriptionEvents,
|
OpenAITranscriptionEvents,
|
||||||
VadDetection,
|
VadDetection,
|
||||||
VerbioTranscriptionEvents,
|
VerbioTranscriptionEvents,
|
||||||
@@ -138,7 +139,11 @@ class TaskGather extends SttTask {
|
|||||||
try {
|
try {
|
||||||
await this.handling(cs, obj);
|
await this.handling(cs, obj);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof SpeechCredentialError) {
|
if (
|
||||||
|
// avoid bargein task with sticky will restart forever
|
||||||
|
// throw exception to stop the loop.
|
||||||
|
!this.sticky &&
|
||||||
|
error instanceof SpeechCredentialError) {
|
||||||
this.logger.info('Gather failed due to SpeechCredentialError, finished!');
|
this.logger.info('Gather failed due to SpeechCredentialError, finished!');
|
||||||
this.notifyTaskDone();
|
this.notifyTaskDone();
|
||||||
return;
|
return;
|
||||||
@@ -546,6 +551,17 @@ class TaskGather extends SttTask {
|
|||||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'cartesia':
|
||||||
|
this.bugname = `${this.bugname_prefix}cartesia_transcribe`;
|
||||||
|
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Transcription,
|
||||||
|
this._onTranscription.bind(this, cs, ep));
|
||||||
|
this.addCustomEventListener(
|
||||||
|
ep, CartesiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||||
|
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||||
|
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.ConnectFailure,
|
||||||
|
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||||
|
break;
|
||||||
|
|
||||||
case 'speechmatics':
|
case 'speechmatics':
|
||||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||||
this.addCustomEventListener(
|
this.addCustomEventListener(
|
||||||
@@ -828,6 +844,10 @@ class TaskGather extends SttTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
_onTranscription(cs, ep, evt, fsEvent) {
|
_onTranscription(cs, ep, evt, fsEvent) {
|
||||||
|
// check if we are in graceful shutdown mode
|
||||||
|
if (ep.gracefulShutdownResolver) {
|
||||||
|
ep.gracefulShutdownResolver();
|
||||||
|
}
|
||||||
// make sure this is not a transcript from answering machine detection
|
// make sure this is not a transcript from answering machine detection
|
||||||
const bugname = fsEvent.getHeader('media-bugname');
|
const bugname = fsEvent.getHeader('media-bugname');
|
||||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||||
@@ -1076,7 +1096,8 @@ class TaskGather extends SttTask {
|
|||||||
if (this.canFallback) {
|
if (this.canFallback) {
|
||||||
ep.stopTranscription({
|
ep.stopTranscription({
|
||||||
vendor: this.vendor,
|
vendor: this.vendor,
|
||||||
bugname: this.bugname
|
bugname: this.bugname,
|
||||||
|
gracefulShutdown: false
|
||||||
})
|
})
|
||||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -32,12 +32,14 @@ class TaskLlmUltravox_S2S extends Task {
|
|||||||
this.auth = this.parent.auth;
|
this.auth = this.parent.auth;
|
||||||
this.connectionOptions = this.parent.connectOptions;
|
this.connectionOptions = this.parent.connectOptions;
|
||||||
|
|
||||||
const {apiKey} = this.auth || {};
|
const {apiKey, agent_id} = this.auth || {};
|
||||||
if (!apiKey) throw new Error('auth.apiKey is required for Vendor: Ultravox');
|
if (!apiKey) throw new Error('auth.apiKey is required for Vendor: Ultravox');
|
||||||
this.apiKey = apiKey;
|
this.apiKey = apiKey;
|
||||||
|
this.agentId = agent_id;
|
||||||
this.actionHook = this.data.actionHook;
|
this.actionHook = this.data.actionHook;
|
||||||
this.eventHook = this.data.eventHook;
|
this.eventHook = this.data.eventHook;
|
||||||
this.toolHook = this.data.toolHook;
|
this.toolHook = this.data.toolHook;
|
||||||
|
this.llmOptions = this.data.llmOptions || {};
|
||||||
|
|
||||||
this.results = {
|
this.results = {
|
||||||
completionReason: 'normal conversation end'
|
completionReason: 'normal conversation end'
|
||||||
@@ -105,24 +107,29 @@ class TaskLlmUltravox_S2S extends Task {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
// merge with any existing tools
|
// merge with any existing tools
|
||||||
this.data.llmOptions.selectedTools = [
|
this.llmOptions.selectedTools = [
|
||||||
...convertedTools,
|
...convertedTools,
|
||||||
...(this.data.llmOptions.selectedTools || [])
|
...(this.llmOptions.selectedTools || [])
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const payload = {
|
const payload = {
|
||||||
...this.data.llmOptions,
|
...this.llmOptions,
|
||||||
model: this.model,
|
...(!this.agentId && {
|
||||||
|
model: this.model,
|
||||||
|
}),
|
||||||
medium: {
|
medium: {
|
||||||
...(this.data.llmOptions.medium || {}),
|
...(this.llmOptions.medium || {}),
|
||||||
serverWebSocket: {
|
serverWebSocket: {
|
||||||
inputSampleRate: 8000,
|
inputSampleRate: 8000,
|
||||||
outputSampleRate: 8000,
|
outputSampleRate: 8000,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
const {statusCode, body} = await request('https://api.ultravox.ai/api/calls', {
|
const baseUrl = 'https://api.ultravox.ai';
|
||||||
|
const url = this.agentId ?
|
||||||
|
`${baseUrl}/api/agents/${this.agentId}/calls` : `${baseUrl}/api/calls`;
|
||||||
|
const {statusCode, body} = await request(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
|
|||||||
@@ -96,6 +96,9 @@ function makeTask(logger, obj, parent) {
|
|||||||
case TaskName.Tag:
|
case TaskName.Tag:
|
||||||
const TaskTag = require('./tag');
|
const TaskTag = require('./tag');
|
||||||
return new TaskTag(logger, data, parent);
|
return new TaskTag(logger, data, parent);
|
||||||
|
case TaskName.Alert:
|
||||||
|
const TaskAlert = require('./alert');
|
||||||
|
return new TaskAlert(logger, data, parent);
|
||||||
}
|
}
|
||||||
|
|
||||||
// should never reach
|
// should never reach
|
||||||
|
|||||||
@@ -48,7 +48,7 @@ class TaskPlay extends Task {
|
|||||||
*/
|
*/
|
||||||
ep.once('playback-start', (evt) => {
|
ep.once('playback-start', (evt) => {
|
||||||
this.logger.debug({evt}, 'Play got playback-start');
|
this.logger.debug({evt}, 'Play got playback-start');
|
||||||
this.cs.stickyEventEmitter.once('uuid_break', (t) => {
|
this.cs.stickyEventEmitter?.once('uuid_break', (t) => {
|
||||||
if (t?.taskId === this.taskId) {
|
if (t?.taskId === this.taskId) {
|
||||||
this.logger.debug(`Play got kill-playback, executing uuid_break, taskId: ${t?.taskId}`);
|
this.logger.debug(`Play got kill-playback, executing uuid_break, taskId: ${t?.taskId}`);
|
||||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
|
|||||||
@@ -17,28 +17,33 @@ class TaskRedirect extends Task {
|
|||||||
async exec(cs) {
|
async exec(cs) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
|
|
||||||
if (cs.requestor instanceof WsRequestor && cs.application.requestor._isAbsoluteUrl(this.actionHook)) {
|
const isAbsoluteUrl = cs.application?.requestor?._isAbsoluteUrl(this.actionHook);
|
||||||
this.logger.info(`Task:performAction redirecting to ${this.actionHook}, requires new ws connection`);
|
|
||||||
try {
|
if (isAbsoluteUrl) {
|
||||||
this.cs.requestor.close();
|
this.logger.info(`TaskRedirect redirecting to new absolute URL ${this.actionHook}, requires new requestor`);
|
||||||
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook}, this.webhook_secret) ;
|
|
||||||
this.cs.application.requestor = requestor;
|
if (cs.requestor instanceof WsRequestor) {
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, `Task:performAction error redirecting to ${this.actionHook}`);
|
|
||||||
}
|
|
||||||
} else if (cs.application.requestor._isAbsoluteUrl(this.actionHook)) {
|
|
||||||
const baseUrl = this.cs.application.requestor.baseUrl;
|
|
||||||
const newUrl = URL.parse(this.actionHook);
|
|
||||||
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
|
|
||||||
if (baseUrl != newBaseUrl) {
|
|
||||||
try {
|
try {
|
||||||
this.logger.info(`Task:redirect updating base url to ${newBaseUrl}`);
|
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook},
|
||||||
const newRequestor = new HttpRequestor(this.logger, cs.accountSid, {url: this.actionHook},
|
cs.accountInfo.account.webhook_secret) ;
|
||||||
cs.accountInfo.account.webhook_secret);
|
cs.requestor.emit('handover', requestor);
|
||||||
this.cs.requestor.removeAllListeners();
|
|
||||||
this.cs.application.requestor = newRequestor;
|
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, `Task:redirect error updating base url to ${this.actionHook}`);
|
this.logger.info(err, `TaskRedirect error redirecting to ${this.actionHook}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
const baseUrl = this.cs.application.requestor.baseUrl;
|
||||||
|
const newUrl = URL.parse(this.actionHook);
|
||||||
|
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
|
||||||
|
if (baseUrl != newBaseUrl) {
|
||||||
|
try {
|
||||||
|
this.logger.info(`Task:redirect updating base url to ${newBaseUrl}`);
|
||||||
|
const newRequestor = new HttpRequestor(this.logger, cs.accountSid, {url: this.actionHook},
|
||||||
|
cs.accountInfo.account.webhook_secret);
|
||||||
|
cs.requestor.emit('handover', newRequestor);
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info(err, `TaskRedirect error updating base url to ${this.actionHook}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
236
lib/tasks/say.js
236
lib/tasks/say.js
@@ -3,24 +3,32 @@ const TtsTask = require('./tts-task');
|
|||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||||
const pollySSMLSplit = require('polly-ssml-split');
|
const pollySSMLSplit = require('polly-ssml-split');
|
||||||
const { SpeechCredentialError } = require('../utils/error');
|
const { SpeechCredentialError } = require('../utils/error');
|
||||||
|
const { sleepFor } = require('../utils/helpers');
|
||||||
|
|
||||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||||
const chunkSize = 1000;
|
// As The text can be used for tts streaming, we need to break lengthy text into smaller chunks
|
||||||
|
// HIGH_WATER_BUFFER_SIZE defined in tts-streaming-buffer.js
|
||||||
|
const chunkSize = 900;
|
||||||
const isSSML = text.startsWith('<speak>');
|
const isSSML = text.startsWith('<speak>');
|
||||||
if (text.length <= chunkSize || !isSSML) return [text];
|
|
||||||
const options = {
|
const options = {
|
||||||
// MIN length
|
|
||||||
softLimit: 100,
|
softLimit: 100,
|
||||||
// MAX length, exclude 15 characters <speak></speak>
|
|
||||||
hardLimit: chunkSize - 15,
|
hardLimit: chunkSize - 15,
|
||||||
// Set of extra split characters (Optional property)
|
|
||||||
extraSplitChars: ',;!?',
|
extraSplitChars: ',;!?',
|
||||||
};
|
};
|
||||||
pollySSMLSplit.configure(options);
|
pollySSMLSplit.configure(options);
|
||||||
try {
|
try {
|
||||||
return pollySSMLSplit.split(text);
|
if (text.length <= chunkSize) return [text];
|
||||||
|
if (isSSML) {
|
||||||
|
return pollySSMLSplit.split(text);
|
||||||
|
} else {
|
||||||
|
// Wrap with <speak> and split
|
||||||
|
const wrapped = `<speak>${text}</speak>`;
|
||||||
|
const splitArr = pollySSMLSplit.split(wrapped);
|
||||||
|
// Remove <speak> and </speak> from each chunk
|
||||||
|
return splitArr.map((str) => str.replace(/^<speak>/, '').replace(/<\/speak>$/, ''));
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.info({err}, 'Error spliting SSML long text');
|
logger.info({err}, 'Error splitting SSML long text');
|
||||||
return [text];
|
return [text];
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
@@ -39,6 +47,9 @@ class TaskSay extends TtsTask {
|
|||||||
assert.ok((typeof this.data.text === 'string' || Array.isArray(this.data.text)) || this.data.stream === true,
|
assert.ok((typeof this.data.text === 'string' || Array.isArray(this.data.text)) || this.data.stream === true,
|
||||||
'Say: either text or stream:true is required');
|
'Say: either text or stream:true is required');
|
||||||
|
|
||||||
|
this.text = this.data.text ? (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
||||||
|
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
|
||||||
|
.flat() : [];
|
||||||
|
|
||||||
if (this.data.stream === true) {
|
if (this.data.stream === true) {
|
||||||
this._isStreamingTts = true;
|
this._isStreamingTts = true;
|
||||||
@@ -46,10 +57,6 @@ class TaskSay extends TtsTask {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this._isStreamingTts = false;
|
this._isStreamingTts = false;
|
||||||
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
|
||||||
.map((t) => breakLengthyTextIfNeeded(this.logger, t))
|
|
||||||
.flat();
|
|
||||||
|
|
||||||
this.loop = this.data.loop || 1;
|
this.loop = this.data.loop || 1;
|
||||||
this.isHandledByPrimaryProvider = true;
|
this.isHandledByPrimaryProvider = true;
|
||||||
}
|
}
|
||||||
@@ -85,6 +92,10 @@ class TaskSay extends TtsTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
this._isStreamingTts = this._isStreamingTts || cs.autoStreamTts;
|
||||||
|
if (this.isStreamingTts) {
|
||||||
|
this.closeOnStreamEmpty = this.closeOnStreamEmpty || this.text.length !== 0;
|
||||||
|
}
|
||||||
if (this.isStreamingTts) await this.handlingStreaming(cs, obj);
|
if (this.isStreamingTts) await this.handlingStreaming(cs, obj);
|
||||||
else await this.handling(cs, obj);
|
else await this.handling(cs, obj);
|
||||||
this.emit('playDone');
|
this.emit('playDone');
|
||||||
@@ -116,6 +127,54 @@ class TaskSay extends TtsTask {
|
|||||||
|
|
||||||
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_open'})
|
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_open'})
|
||||||
.catch((err) => this.logger.info({err}, 'TaskSay:handlingStreaming - Error sending'));
|
.catch((err) => this.logger.info({err}, 'TaskSay:handlingStreaming - Error sending'));
|
||||||
|
|
||||||
|
if (this.text.length !== 0) {
|
||||||
|
this.logger.info('TaskSay:handlingStreaming - sending text to TTS stream');
|
||||||
|
for (const t of this.text) {
|
||||||
|
const result = await cs._internalTtsStreamingBufferTokens(t);
|
||||||
|
if (result?.status === 'failed') {
|
||||||
|
if (result.reason === 'full') {
|
||||||
|
// Retry logic for full buffer
|
||||||
|
const maxRetries = 5;
|
||||||
|
let backoffMs = 1000;
|
||||||
|
for (let retryCount = 0; retryCount < maxRetries && !this.killed; retryCount++) {
|
||||||
|
this.logger.info(
|
||||||
|
`TaskSay:handlingStreaming - retry ${retryCount + 1}/${maxRetries} after ${backoffMs}ms`);
|
||||||
|
await sleepFor(backoffMs);
|
||||||
|
|
||||||
|
const retryResult = await cs._internalTtsStreamingBufferTokens(t);
|
||||||
|
|
||||||
|
// Exit retry loop on success
|
||||||
|
if (retryResult?.status !== 'failed') {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle failure for reason other than full buffer
|
||||||
|
if (retryResult.reason !== 'full') {
|
||||||
|
this.logger.info(
|
||||||
|
{result: retryResult}, 'TaskSay:handlingStreaming - TTS stream failed to buffer tokens');
|
||||||
|
throw new Error(`TTS stream failed to buffer tokens: ${retryResult.reason}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Last retry attempt failed
|
||||||
|
if (retryCount === maxRetries - 1) {
|
||||||
|
this.logger.info('TaskSay:handlingStreaming - Maximum retries exceeded for full buffer');
|
||||||
|
throw new Error('TTS stream buffer full - maximum retries exceeded');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Increase backoff for next retry
|
||||||
|
backoffMs = Math.min(backoffMs * 1.5, 10000);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Immediate failure for non-full buffer issues
|
||||||
|
this.logger.info({result}, 'TaskSay:handlingStreaming - TTS stream failed to buffer tokens');
|
||||||
|
throw new Error(`TTS stream failed to buffer tokens: ${result.reason}`);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
await cs._lccTtsFlush();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info({err}, 'TaskSay:handlingStreaming - Error setting channel vars');
|
this.logger.info({err}, 'TaskSay:handlingStreaming - Error setting channel vars');
|
||||||
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
|
cs.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
|
||||||
@@ -188,6 +247,7 @@ class TaskSay extends TtsTask {
|
|||||||
throw new SpeechCredentialError(error.message);
|
throw new SpeechCredentialError(error.message);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
let filepath;
|
let filepath;
|
||||||
try {
|
try {
|
||||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
||||||
@@ -204,65 +264,113 @@ class TaskSay extends TtsTask {
|
|||||||
await this.playToConfMember(ep, memberId, confName, confUuid, filepath[segment]);
|
await this.playToConfMember(ep, memberId, confName, confUuid, filepath[segment]);
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
|
let playbackId;
|
||||||
const isStreaming = filepath[segment].startsWith('say:{');
|
const isStreaming = filepath[segment].startsWith('say:{');
|
||||||
if (isStreaming) {
|
if (isStreaming) {
|
||||||
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
||||||
if (arr) this.logger.debug(`Say:exec sending streaming tts request: ${arr[1].substring(0, 64)}..`);
|
if (arr) this.logger.debug(`Say:exec sending streaming tts request: ${arr[1].substring(0, 64)}..`);
|
||||||
}
|
}
|
||||||
else this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
|
else {
|
||||||
|
this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const onPlaybackStop = (evt) => {
|
||||||
|
try {
|
||||||
|
this.logger.debug({evt},
|
||||||
|
`Say got playback-stop ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
|
||||||
|
const unmatchedResponse = !!evt.variable_tts_playback_id && evt.variable_tts_playback_id !== playbackId;
|
||||||
|
if (unmatchedResponse) {
|
||||||
|
this.logger.info({currentPlaybackId: playbackId, stopPPlaybackId: evt.variable_tts_playback_id},
|
||||||
|
'Say:exec discarding playback-stop for earlier play');
|
||||||
|
ep.once('playback-stop', this._boundOnPlaybackStop);
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.notifyStatus({event: 'stop-playback'});
|
||||||
|
this.notifiedPlayBackStop = true;
|
||||||
|
const tts_error = evt.variable_tts_error;
|
||||||
|
// some tts vendor may not provide response code, so we default to 200
|
||||||
|
let response_code = 200;
|
||||||
|
// Check if any property ends with _response_code
|
||||||
|
for (const [key, value] of Object.entries(evt)) {
|
||||||
|
if (key.endsWith('_response_code')) {
|
||||||
|
response_code = parseInt(value, 10);
|
||||||
|
if (isNaN(response_code)) {
|
||||||
|
this.logger.info(`Say:exec playback-stop - Invalid response code: ${value}`);
|
||||||
|
response_code = 0;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (tts_error ||
|
||||||
|
// error response codes indicate failure
|
||||||
|
response_code <= 199 || response_code >= 300) {
|
||||||
|
writeAlerts({
|
||||||
|
account_sid,
|
||||||
|
alert_type: AlertType.TTS_FAILURE,
|
||||||
|
vendor,
|
||||||
|
detail: evt.variable_tts_error || `TTS playback failed with response code ${response_code}`,
|
||||||
|
target_sid
|
||||||
|
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
!tts_error &&
|
||||||
|
//2xx response codes indicate success
|
||||||
|
199 < response_code && response_code < 300 &&
|
||||||
|
evt.variable_tts_cache_filename &&
|
||||||
|
!this.killed &&
|
||||||
|
// if tts cache is not disabled, add the file to cache
|
||||||
|
!this.disableTtsCache
|
||||||
|
) {
|
||||||
|
const text = parseTextFromSayString(this.text[segment]);
|
||||||
|
addFileToCache(evt.variable_tts_cache_filename, {
|
||||||
|
account_sid,
|
||||||
|
vendor,
|
||||||
|
language,
|
||||||
|
voice,
|
||||||
|
engine,
|
||||||
|
model: this.model || this.model_id,
|
||||||
|
text,
|
||||||
|
instructions: this.instructions
|
||||||
|
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (this._playResolve) {
|
||||||
|
(tts_error ||
|
||||||
|
// error response codes indicate failure
|
||||||
|
response_code <= 199 || response_code >= 300
|
||||||
|
) ?
|
||||||
|
this._playReject(
|
||||||
|
new Error(evt.variable_tts_error || `TTS playback failed with response code ${response_code}`)
|
||||||
|
) : this._playResolve();
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info({err}, 'Error handling playback-stop event');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
this._boundOnPlaybackStop = onPlaybackStop.bind(this);
|
||||||
|
|
||||||
ep.once('playback-start', (evt) => {
|
ep.once('playback-start', (evt) => {
|
||||||
this.logger.debug({evt}, 'Say got playback-start');
|
try {
|
||||||
if (this.otelSpan) {
|
playbackId = evt.variable_tts_playback_id;
|
||||||
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
|
this.logger.debug({evt},
|
||||||
this.otelSpan.end();
|
`Say got playback-start ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
|
||||||
this.otelSpan = null;
|
if (this.otelSpan) {
|
||||||
if (evt.variable_tts_cache_filename) {
|
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
|
||||||
cs.trackTmpFile(evt.variable_tts_cache_filename);
|
this.otelSpan.end();
|
||||||
|
this.otelSpan = null;
|
||||||
|
if (evt.variable_tts_cache_filename) {
|
||||||
|
cs.trackTmpFile(evt.variable_tts_cache_filename);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info({err}, 'Error handling playback-start event');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
ep.once('playback-stop', (evt) => {
|
ep.once('playback-stop', this._boundOnPlaybackStop);
|
||||||
this.logger.debug({evt}, 'Say got playback-stop');
|
|
||||||
this.notifyStatus({event: 'stop-playback'});
|
|
||||||
this.notifiedPlayBackStop = true;
|
|
||||||
const tts_error = evt.variable_tts_error;
|
|
||||||
let response_code = 200;
|
|
||||||
// Check if any property ends with _response_code
|
|
||||||
for (const [key, value] of Object.entries(evt)) {
|
|
||||||
if (key.endsWith('_response_code')) {
|
|
||||||
response_code = parseInt(value, 10) || 200;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (tts_error) {
|
|
||||||
writeAlerts({
|
|
||||||
account_sid,
|
|
||||||
alert_type: AlertType.TTS_FAILURE,
|
|
||||||
vendor,
|
|
||||||
detail: evt.variable_tts_error,
|
|
||||||
target_sid
|
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
|
||||||
}
|
|
||||||
if (!tts_error && response_code < 300 && evt.variable_tts_cache_filename && !this.killed) {
|
|
||||||
const text = parseTextFromSayString(this.text[segment]);
|
|
||||||
addFileToCache(evt.variable_tts_cache_filename, {
|
|
||||||
account_sid,
|
|
||||||
vendor,
|
|
||||||
language,
|
|
||||||
voice,
|
|
||||||
engine,
|
|
||||||
model: this.model || this.model_id,
|
|
||||||
text,
|
|
||||||
instructions: this.instructions
|
|
||||||
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (this._playResolve) {
|
|
||||||
(tts_error || response_code >= 300) ? this._playReject(new Error(evt.variable_tts_error)) :
|
|
||||||
this._playResolve();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
// wait for playback-stop event received to confirm if the playback is successful
|
// wait for playback-stop event received to confirm if the playback is successful
|
||||||
this._playPromise = new Promise((resolve, reject) => {
|
this._playPromise = new Promise((resolve, reject) => {
|
||||||
this._playResolve = resolve;
|
this._playResolve = resolve;
|
||||||
@@ -338,6 +446,7 @@ class TaskSay extends TtsTask {
|
|||||||
.replace('playht_', 'playht.')
|
.replace('playht_', 'playht.')
|
||||||
.replace('cartesia_', 'cartesia.')
|
.replace('cartesia_', 'cartesia.')
|
||||||
.replace('rimelabs_', 'rimelabs.')
|
.replace('rimelabs_', 'rimelabs.')
|
||||||
|
.replace('inworld_', 'inworld.')
|
||||||
.replace('verbio_', 'verbio.')
|
.replace('verbio_', 'verbio.')
|
||||||
.replace('elevenlabs_', 'elevenlabs.');
|
.replace('elevenlabs_', 'elevenlabs.');
|
||||||
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
||||||
@@ -402,6 +511,11 @@ const spanMapping = {
|
|||||||
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
|
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||||
'rimelabs.connect_time_ms': 'connect_ms',
|
'rimelabs.connect_time_ms': 'connect_ms',
|
||||||
'rimelabs.final_response_time_ms': 'final_response_ms',
|
'rimelabs.final_response_time_ms': 'final_response_ms',
|
||||||
|
// inworld
|
||||||
|
'inworld.name_lookup_time_ms': 'name_lookup_ms',
|
||||||
|
'inworld.connect_time_ms': 'connect_ms',
|
||||||
|
'inworld.final_response_time_ms': 'final_response_ms',
|
||||||
|
'inworld.x_envoy_upstream_service_time': 'upstream_service_time',
|
||||||
// verbio
|
// verbio
|
||||||
'verbio.name_lookup_time_ms': 'name_lookup_ms',
|
'verbio.name_lookup_time_ms': 'name_lookup_ms',
|
||||||
'verbio.connect_time_ms': 'connect_ms',
|
'verbio.connect_time_ms': 'connect_ms',
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const {
|
|||||||
TranscribeStatus,
|
TranscribeStatus,
|
||||||
AssemblyAiTranscriptionEvents,
|
AssemblyAiTranscriptionEvents,
|
||||||
VoxistTranscriptionEvents,
|
VoxistTranscriptionEvents,
|
||||||
|
CartesiaTranscriptionEvents,
|
||||||
OpenAITranscriptionEvents,
|
OpenAITranscriptionEvents,
|
||||||
VerbioTranscriptionEvents,
|
VerbioTranscriptionEvents,
|
||||||
SpeechmaticsTranscriptionEvents
|
SpeechmaticsTranscriptionEvents
|
||||||
@@ -136,13 +137,18 @@ class TaskTranscribe extends SttTask {
|
|||||||
stopTranscription = true;
|
stopTranscription = true;
|
||||||
this.ep.stopTranscription({
|
this.ep.stopTranscription({
|
||||||
vendor: this.vendor,
|
vendor: this.vendor,
|
||||||
bugname: this.bugname
|
bugname: this.bugname,
|
||||||
|
gracefulShutdown: this.paused ? false : true
|
||||||
})
|
})
|
||||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||||
}
|
}
|
||||||
if (this.transcribing2 && this.ep2?.connected) {
|
if (this.transcribing2 && this.ep2?.connected) {
|
||||||
stopTranscription = true;
|
stopTranscription = true;
|
||||||
this.ep2.stopTranscription({vendor: this.vendor, bugname: this.bugname})
|
this.ep2.stopTranscription({
|
||||||
|
vendor: this.vendor,
|
||||||
|
bugname: this.bugname,
|
||||||
|
gracefulShutdown: this.paused ? false : true
|
||||||
|
})
|
||||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -312,6 +318,17 @@ class TaskTranscribe extends SttTask {
|
|||||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||||
break;
|
break;
|
||||||
|
|
||||||
|
case 'cartesia':
|
||||||
|
this.bugname = `${this.bugname_prefix}cartesia_transcribe`;
|
||||||
|
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Transcription,
|
||||||
|
this._onTranscription.bind(this, cs, ep, channel));
|
||||||
|
this.addCustomEventListener(ep,
|
||||||
|
CartesiaTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||||
|
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||||
|
this.addCustomEventListener(ep, CartesiaTranscriptionEvents.ConnectFailure,
|
||||||
|
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||||
|
break;
|
||||||
|
|
||||||
case 'speechmatics':
|
case 'speechmatics':
|
||||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||||
this.addCustomEventListener(
|
this.addCustomEventListener(
|
||||||
@@ -409,6 +426,10 @@ class TaskTranscribe extends SttTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async _onTranscription(cs, ep, channel, evt, fsEvent) {
|
async _onTranscription(cs, ep, channel, evt, fsEvent) {
|
||||||
|
// check if we are in graceful shutdown mode
|
||||||
|
if (ep.gracefulShutdownResolver) {
|
||||||
|
ep.gracefulShutdownResolver();
|
||||||
|
}
|
||||||
// make sure this is not a transcript from answering machine detection
|
// make sure this is not a transcript from answering machine detection
|
||||||
const bugname = fsEvent.getHeader('media-bugname');
|
const bugname = fsEvent.getHeader('media-bugname');
|
||||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||||
@@ -691,7 +712,8 @@ class TaskTranscribe extends SttTask {
|
|||||||
if (this.canFallback) {
|
if (this.canFallback) {
|
||||||
_ep.stopTranscription({
|
_ep.stopTranscription({
|
||||||
vendor: this.vendor,
|
vendor: this.vendor,
|
||||||
bugname: this.bugname
|
bugname: this.bugname,
|
||||||
|
gracefulShutdown: false
|
||||||
})
|
})
|
||||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -185,6 +185,9 @@ class TtsTask extends Task {
|
|||||||
} else if (vendor === 'rimelabs') {
|
} else if (vendor === 'rimelabs') {
|
||||||
credentials = credentials || {};
|
credentials = credentials || {};
|
||||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||||
|
} else if (vendor === 'inworld') {
|
||||||
|
credentials = credentials || {};
|
||||||
|
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||||
} else if (vendor === 'whisper') {
|
} else if (vendor === 'whisper') {
|
||||||
credentials = credentials || {};
|
credentials = credentials || {};
|
||||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||||
|
|||||||
@@ -418,7 +418,11 @@ module.exports = (logger) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (ep.connected) {
|
if (ep.connected) {
|
||||||
ep.stopTranscription({vendor, bugname})
|
ep.stopTranscription({
|
||||||
|
vendor,
|
||||||
|
bugname,
|
||||||
|
gracefulShutdown: false
|
||||||
|
})
|
||||||
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
||||||
task.emit('amd', {type: AmdEvents.Stopped});
|
task.emit('amd', {type: AmdEvents.Stopped});
|
||||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"TaskName": {
|
"TaskName": {
|
||||||
|
"Alert": "alert",
|
||||||
"Answer": "answer",
|
"Answer": "answer",
|
||||||
"Conference": "conference",
|
"Conference": "conference",
|
||||||
"Config": "config",
|
"Config": "config",
|
||||||
@@ -167,6 +168,12 @@
|
|||||||
"ConnectFailure": "voxist_transcribe::connect_failed",
|
"ConnectFailure": "voxist_transcribe::connect_failed",
|
||||||
"Connect": "voxist_transcribe::connect"
|
"Connect": "voxist_transcribe::connect"
|
||||||
},
|
},
|
||||||
|
"CartesiaTranscriptionEvents": {
|
||||||
|
"Transcription": "cartesia_transcribe::transcription",
|
||||||
|
"Error": "cartesia_transcribe::error",
|
||||||
|
"ConnectFailure": "cartesia_transcribe::connect_failed",
|
||||||
|
"Connect": "cartesia_transcribe::connect"
|
||||||
|
},
|
||||||
"VadDetection": {
|
"VadDetection": {
|
||||||
"Detection": "vad_detect:detection"
|
"Detection": "vad_detect:detection"
|
||||||
},
|
},
|
||||||
@@ -237,6 +244,7 @@
|
|||||||
"KillReason": {
|
"KillReason": {
|
||||||
"Hangup": "hangup",
|
"Hangup": "hangup",
|
||||||
"Replaced": "replaced",
|
"Replaced": "replaced",
|
||||||
|
"ReferComplete": "refer-complete",
|
||||||
"MediaTimeout": "media_timeout"
|
"MediaTimeout": "media_timeout"
|
||||||
},
|
},
|
||||||
"HookMsgTypes": [
|
"HookMsgTypes": [
|
||||||
|
|||||||
@@ -110,6 +110,7 @@ const speechMapper = (cred) => {
|
|||||||
const o = JSON.parse(decrypt(credential));
|
const o = JSON.parse(decrypt(credential));
|
||||||
obj.api_key = o.api_key;
|
obj.api_key = o.api_key;
|
||||||
obj.model_id = o.model_id;
|
obj.model_id = o.model_id;
|
||||||
|
obj.stt_model_id = o.stt_model_id;
|
||||||
obj.embedding = o.embedding;
|
obj.embedding = o.embedding;
|
||||||
obj.options = o.options;
|
obj.options = o.options;
|
||||||
}
|
}
|
||||||
@@ -119,9 +120,16 @@ const speechMapper = (cred) => {
|
|||||||
obj.model_id = o.model_id;
|
obj.model_id = o.model_id;
|
||||||
obj.options = o.options;
|
obj.options = o.options;
|
||||||
}
|
}
|
||||||
|
else if ('inworld' === obj.vendor) {
|
||||||
|
const o = JSON.parse(decrypt(credential));
|
||||||
|
obj.api_key = o.api_key;
|
||||||
|
obj.model_id = o.model_id;
|
||||||
|
obj.options = o.options;
|
||||||
|
}
|
||||||
else if ('assemblyai' === obj.vendor) {
|
else if ('assemblyai' === obj.vendor) {
|
||||||
const o = JSON.parse(decrypt(credential));
|
const o = JSON.parse(decrypt(credential));
|
||||||
obj.api_key = o.api_key;
|
obj.api_key = o.api_key;
|
||||||
|
obj.service_version = o.service_version;
|
||||||
}
|
}
|
||||||
else if ('voxist' === obj.vendor) {
|
else if ('voxist' === obj.vendor) {
|
||||||
const o = JSON.parse(decrypt(credential));
|
const o = JSON.parse(decrypt(credential));
|
||||||
|
|||||||
115
lib/utils/media-endpoint.js
Normal file
115
lib/utils/media-endpoint.js
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
const {
|
||||||
|
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||||
|
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||||
|
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
||||||
|
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
|
||||||
|
} = require('../config');
|
||||||
|
const { sleepFor } = require('./helpers');
|
||||||
|
|
||||||
|
const createMediaEndpoint = async(srf, logger, {
|
||||||
|
activeMs,
|
||||||
|
drachtioFsmrfOptions = {},
|
||||||
|
onHoldMusic,
|
||||||
|
inbandDtmfEnabled,
|
||||||
|
mediaTimeoutHandler,
|
||||||
|
} = {}) => {
|
||||||
|
const { getFreeswitch } = srf.locals;
|
||||||
|
const ms = activeMs || getFreeswitch();
|
||||||
|
if (!ms)
|
||||||
|
throw new Error('no available Freeswitch for creating media endpoint');
|
||||||
|
|
||||||
|
const ep = await ms.createEndpoint(drachtioFsmrfOptions);
|
||||||
|
|
||||||
|
// Configure the endpoint
|
||||||
|
const opts = {
|
||||||
|
...(onHoldMusic && {holdMusic: `shout://${onHoldMusic.replace(/^https?:\/\//, '')}`}),
|
||||||
|
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
|
||||||
|
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
|
||||||
|
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
|
||||||
|
};
|
||||||
|
if (Object.keys(opts).length > 0) {
|
||||||
|
ep.set(opts);
|
||||||
|
}
|
||||||
|
// inbandDtmfEnabled
|
||||||
|
if (inbandDtmfEnabled) {
|
||||||
|
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
||||||
|
ep.execute('start_dtmf').catch((err) => {
|
||||||
|
logger.error('Error starting inband DTMF', { error: err });
|
||||||
|
});
|
||||||
|
ep.inbandDtmfEnabled = true;
|
||||||
|
}
|
||||||
|
// Handle Media Timeout
|
||||||
|
if (mediaTimeoutHandler) {
|
||||||
|
ep.once('destroy', (evt) => {
|
||||||
|
mediaTimeoutHandler(evt, ep);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
// Handle graceful shutdown for endpoint if required
|
||||||
|
if (JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS > 0) {
|
||||||
|
const getEpGracefulShutdownPromise = () => {
|
||||||
|
if (!ep.gracefulShutdownPromise) {
|
||||||
|
ep.gracefulShutdownPromise = new Promise((resolve) => {
|
||||||
|
// this resolver will be called when stt task received transcription.
|
||||||
|
ep.gracefulShutdownResolver = () => {
|
||||||
|
resolve();
|
||||||
|
ep.gracefulShutdownPromise = null;
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return ep.gracefulShutdownPromise;
|
||||||
|
};
|
||||||
|
|
||||||
|
const gracefulShutdownHandler = async() => {
|
||||||
|
// resolve when one of the following happens:
|
||||||
|
// 1. stt task received transcription
|
||||||
|
// 2. JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS passed
|
||||||
|
await Promise.race([
|
||||||
|
getEpGracefulShutdownPromise(),
|
||||||
|
sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS)
|
||||||
|
]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const origStartTranscription = ep.startTranscription.bind(ep);
|
||||||
|
ep.startTranscription = async(...args) => {
|
||||||
|
try {
|
||||||
|
const result = await origStartTranscription(...args);
|
||||||
|
ep.isTranscribeActive = true;
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
ep.isTranscribeActive = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origStopTranscription = ep.stopTranscription.bind(ep);
|
||||||
|
ep.stopTranscription = async(opts = {}, ...args) => {
|
||||||
|
const { gracefulShutdown = true, ...others } = opts;
|
||||||
|
if (ep.isTranscribeActive && gracefulShutdown) {
|
||||||
|
// only wait for graceful shutdown if transcription is active
|
||||||
|
await gracefulShutdownHandler();
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const result = await origStopTranscription({...others}, ...args);
|
||||||
|
ep.isTranscribeActive = false;
|
||||||
|
return result;
|
||||||
|
} catch (err) {
|
||||||
|
ep.isTranscribeActive = false;
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const origDestroy = ep.destroy.bind(ep);
|
||||||
|
ep.destroy = async() => {
|
||||||
|
if (ep.isTranscribeActive) {
|
||||||
|
await gracefulShutdownHandler();
|
||||||
|
}
|
||||||
|
return await origDestroy();
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return ep;
|
||||||
|
};
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
createMediaEndpoint,
|
||||||
|
};
|
||||||
@@ -16,13 +16,7 @@ const crypto = require('crypto');
|
|||||||
const HttpRequestor = require('./http-requestor');
|
const HttpRequestor = require('./http-requestor');
|
||||||
const WsRequestor = require('./ws-requestor');
|
const WsRequestor = require('./ws-requestor');
|
||||||
const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils');
|
const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils');
|
||||||
const {
|
const { createMediaEndpoint } = require('./media-endpoint');
|
||||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
|
||||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
|
||||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
|
||||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
|
|
||||||
} = require('../config');
|
|
||||||
const { sleepFor } = require('./helpers');
|
|
||||||
|
|
||||||
class SingleDialer extends Emitter {
|
class SingleDialer extends Emitter {
|
||||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
|
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
|
||||||
@@ -98,6 +92,7 @@ class SingleDialer extends Emitter {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
this.ms = ms;
|
this.ms = ms;
|
||||||
|
this.srf = srf;
|
||||||
let uri, to, inviteSpan;
|
let uri, to, inviteSpan;
|
||||||
try {
|
try {
|
||||||
switch (this.target.type) {
|
switch (this.target.type) {
|
||||||
@@ -139,8 +134,7 @@ class SingleDialer extends Emitter {
|
|||||||
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
|
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
|
||||||
this.serviceUrl = srf.locals.serviceUrl;
|
this.serviceUrl = srf.locals.serviceUrl;
|
||||||
|
|
||||||
this.ep = await ms.createEndpoint();
|
this.ep = await this._createMediaEndpoint();
|
||||||
this._configMsEndpoint();
|
|
||||||
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -352,43 +346,19 @@ class SingleDialer extends Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
_configMsEndpoint() {
|
async _handleMediaTimeout(evt, ep) {
|
||||||
const opts = {
|
this.logger.info({evt}, 'SingleDialer:_handleMediaTimeout - media timeout event received');
|
||||||
...(this.onHoldMusic && {holdMusic: `shout://${this.onHoldMusic.replace(/^https?:\/\//, '')}`}),
|
this.dialTask.kill(this.dialTask.cs, 'media-timeout');
|
||||||
...(JAMBONES_USE_FREESWITCH_TIMER_FD && {timer_name: 'timerfd'}),
|
}
|
||||||
...(JAMBONES_MEDIA_TIMEOUT_MS && {media_timeout: JAMBONES_MEDIA_TIMEOUT_MS}),
|
|
||||||
...(JAMBONES_MEDIA_HOLD_TIMEOUT_MS && {media_hold_timeout: JAMBONES_MEDIA_HOLD_TIMEOUT_MS})
|
|
||||||
};
|
|
||||||
if (Object.keys(opts).length > 0) {
|
|
||||||
this.ep.set(opts);
|
|
||||||
}
|
|
||||||
if (this.dialTask?.inbandDtmfEnabled && !this.ep.inbandDtmfEnabled) {
|
|
||||||
// https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod-dptools/6587132/#0-about
|
|
||||||
try {
|
|
||||||
this.ep.execute('start_dtmf');
|
|
||||||
this.ep.inbandDtmfEnabled = true;
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.info(err, 'place-outdial:_configMsEndpoint - error enable inband DTMF');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const origDestroy = this.ep.destroy.bind(this.ep);
|
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
|
||||||
this.ep.destroy = async() => {
|
return await createMediaEndpoint(this.srf, this.logger, {
|
||||||
try {
|
acactiveMs: this.ms,
|
||||||
if (this.dialTask.transcribeTask && JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS) {
|
drachtioFsmrfOptions,
|
||||||
// transcribe task is being used, wait for some time before destroy
|
onHoldMusic: this.onHoldMusic,
|
||||||
// if final transcription is received but endpoint is already closed,
|
inbandDtmfEnabled: this.dialTask?.inbandDtmfEnabled,
|
||||||
// freeswitch module will not be able to send the transcription
|
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
|
||||||
|
});
|
||||||
this.logger.info('SingleDialer:_configMsEndpoint -' +
|
|
||||||
' Dial with transcribe task, wait for some time before destroy');
|
|
||||||
await sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS);
|
|
||||||
}
|
|
||||||
await origDestroy();
|
|
||||||
} catch (err) {
|
|
||||||
this.logger.error(err, 'SingleDialer:_configMsEndpoint - error destroying endpoint');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -528,8 +498,7 @@ class SingleDialer extends Emitter {
|
|||||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||||
|
|
||||||
this.logger.debug('SingleDialer:reAnchorMedia: re-anchoring media after partial media');
|
this.logger.debug('SingleDialer:reAnchorMedia: re-anchoring media after partial media');
|
||||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||||
this._configMsEndpoint();
|
|
||||||
await this.dlg.modify(this.ep.local.sdp, {
|
await this.dlg.modify(this.ep.local.sdp, {
|
||||||
headers: {
|
headers: {
|
||||||
'X-Reason': 'anchor-media'
|
'X-Reason': 'anchor-media'
|
||||||
|
|||||||
@@ -110,6 +110,10 @@ const stickyVars = {
|
|||||||
voxist: [
|
voxist: [
|
||||||
'VOXIST_API_KEY',
|
'VOXIST_API_KEY',
|
||||||
],
|
],
|
||||||
|
cartesia: [
|
||||||
|
'CARTESIA_API_KEY',
|
||||||
|
'CARTESIA_MODEL_ID'
|
||||||
|
],
|
||||||
speechmatics: [
|
speechmatics: [
|
||||||
'SPEECHMATICS_API_KEY',
|
'SPEECHMATICS_API_KEY',
|
||||||
'SPEECHMATICS_HOST',
|
'SPEECHMATICS_HOST',
|
||||||
@@ -519,16 +523,27 @@ const normalizeAws = (evt, channel, language) => {
|
|||||||
|
|
||||||
const normalizeAssemblyAi = (evt, channel, language) => {
|
const normalizeAssemblyAi = (evt, channel, language) => {
|
||||||
const copy = JSON.parse(JSON.stringify(evt));
|
const copy = JSON.parse(JSON.stringify(evt));
|
||||||
|
const alternatives = [];
|
||||||
|
let is_final = false;
|
||||||
|
if (evt.type && evt.type === 'Turn') {
|
||||||
|
// v3 is here
|
||||||
|
alternatives.push({
|
||||||
|
confidence: evt.end_of_turn_confidence,
|
||||||
|
transcript: evt.transcript,
|
||||||
|
});
|
||||||
|
is_final = evt.end_of_turn;
|
||||||
|
} else {
|
||||||
|
alternatives.push({
|
||||||
|
confidence: evt.confidence,
|
||||||
|
transcript: evt.text,
|
||||||
|
});
|
||||||
|
is_final = evt.message_type === 'FinalTranscript';
|
||||||
|
}
|
||||||
return {
|
return {
|
||||||
language_code: language,
|
language_code: language,
|
||||||
channel_tag: channel,
|
channel_tag: channel,
|
||||||
is_final: evt.message_type === 'FinalTranscript',
|
is_final,
|
||||||
alternatives: [
|
alternatives,
|
||||||
{
|
|
||||||
confidence: evt.confidence,
|
|
||||||
transcript: evt.text,
|
|
||||||
}
|
|
||||||
],
|
|
||||||
vendor: {
|
vendor: {
|
||||||
name: 'assemblyai',
|
name: 'assemblyai',
|
||||||
evt: copy
|
evt: copy
|
||||||
@@ -555,6 +570,25 @@ const normalizeVoxist = (evt, channel, language) => {
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeCartesia = (evt, channel, language) => {
|
||||||
|
const copy = JSON.parse(JSON.stringify(evt));
|
||||||
|
return {
|
||||||
|
language_code: language,
|
||||||
|
channel_tag: channel,
|
||||||
|
is_final: evt.is_final,
|
||||||
|
alternatives: [
|
||||||
|
{
|
||||||
|
confidence: 1.00,
|
||||||
|
transcript: evt.text,
|
||||||
|
}
|
||||||
|
],
|
||||||
|
vendor: {
|
||||||
|
name: 'cartesia',
|
||||||
|
evt: copy
|
||||||
|
}
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const normalizeSpeechmatics = (evt, channel, language) => {
|
const normalizeSpeechmatics = (evt, channel, language) => {
|
||||||
const copy = JSON.parse(JSON.stringify(evt));
|
const copy = JSON.parse(JSON.stringify(evt));
|
||||||
const is_final = evt.message === 'AddTranscript';
|
const is_final = evt.message === 'AddTranscript';
|
||||||
@@ -636,6 +670,8 @@ module.exports = (logger) => {
|
|||||||
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
|
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
|
||||||
case 'voxist':
|
case 'voxist':
|
||||||
return normalizeVoxist(evt, channel, language);
|
return normalizeVoxist(evt, channel, language);
|
||||||
|
case 'cartesia':
|
||||||
|
return normalizeCartesia(evt, channel, language);
|
||||||
case 'verbio':
|
case 'verbio':
|
||||||
return normalizeVerbio(evt, channel, language);
|
return normalizeVerbio(evt, channel, language);
|
||||||
case 'speechmatics':
|
case 'speechmatics':
|
||||||
@@ -993,8 +1029,28 @@ module.exports = (logger) => {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if ('assemblyai' === vendor) {
|
else if ('assemblyai' === vendor) {
|
||||||
|
const serviceVersion = rOpts.assemblyAiOptions?.serviceVersion || sttCredentials.service_version || 'v2';
|
||||||
|
const {
|
||||||
|
formatTurns,
|
||||||
|
endOfTurnConfidenceThreshold,
|
||||||
|
minEndOfTurnSilenceWhenConfident,
|
||||||
|
maxTurnSilence
|
||||||
|
} = rOpts.assemblyAiOptions || {};
|
||||||
opts = {
|
opts = {
|
||||||
...opts,
|
...opts,
|
||||||
|
ASSEMBLYAI_API_VERSION: serviceVersion,
|
||||||
|
...(serviceVersion === 'v3' && {
|
||||||
|
...(formatTurns && {
|
||||||
|
ASSEMBLYAI_FORMAT_TURNS: formatTurns
|
||||||
|
}),
|
||||||
|
...(endOfTurnConfidenceThreshold && {
|
||||||
|
ASSEMBLYAI_END_OF_TURN_CONFIDENCE_THRESHOLD: endOfTurnConfidenceThreshold
|
||||||
|
}),
|
||||||
|
ASSEMBLYAI_MIN_END_OF_TURN_SILENCE_WHEN_CONFIDENT: minEndOfTurnSilenceWhenConfident || 500,
|
||||||
|
...(maxTurnSilence && {
|
||||||
|
ASSEMBLYAI_MAX_TURN_SILENCE: maxTurnSilence
|
||||||
|
}),
|
||||||
|
}),
|
||||||
...(sttCredentials.api_key) &&
|
...(sttCredentials.api_key) &&
|
||||||
{ASSEMBLYAI_API_KEY: sttCredentials.api_key},
|
{ASSEMBLYAI_API_KEY: sttCredentials.api_key},
|
||||||
...(rOpts.hints?.length > 0 &&
|
...(rOpts.hints?.length > 0 &&
|
||||||
@@ -1008,6 +1064,16 @@ module.exports = (logger) => {
|
|||||||
{VOXIST_API_KEY: sttCredentials.api_key},
|
{VOXIST_API_KEY: sttCredentials.api_key},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
else if ('cartesia' === vendor) {
|
||||||
|
opts = {
|
||||||
|
...opts,
|
||||||
|
...(sttCredentials.api_key &&
|
||||||
|
{CARTESIA_API_KEY: sttCredentials.api_key}),
|
||||||
|
...(sttCredentials.stt_model_id && {
|
||||||
|
CARTESIA_MODEL_ID: sttCredentials.stt_model_id
|
||||||
|
})
|
||||||
|
};
|
||||||
|
}
|
||||||
else if ('openai' === vendor) {
|
else if ('openai' === vendor) {
|
||||||
const {openaiOptions = {}} = rOpts;
|
const {openaiOptions = {}} = rOpts;
|
||||||
const model = openaiOptions.model || rOpts.model || sttCredentials.model_id || 'whisper-1';
|
const model = openaiOptions.model || rOpts.model || sttCredentials.model_id || 'whisper-1';
|
||||||
|
|||||||
@@ -132,8 +132,8 @@ class WsRequestor extends BaseRequestor {
|
|||||||
|
|
||||||
while (retryCount <= this.maxReconnects) {
|
while (retryCount <= this.maxReconnects) {
|
||||||
try {
|
try {
|
||||||
this.logger.error({retryCount, maxReconnects: this.maxReconnects},
|
this.logger.debug({retryCount, maxReconnects: this.maxReconnects},
|
||||||
'WsRequestor:request - attempting connection');
|
'WsRequestor:request - attempting connection retry');
|
||||||
|
|
||||||
// Ensure clean state before each connection attempt
|
// Ensure clean state before each connection attempt
|
||||||
if (this.ws) {
|
if (this.ws) {
|
||||||
@@ -141,38 +141,29 @@ class WsRequestor extends BaseRequestor {
|
|||||||
this.ws = null;
|
this.ws = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.logger.error({retryCount}, 'WsRequestor:request - calling _connect()');
|
|
||||||
const startAt = process.hrtime();
|
const startAt = process.hrtime();
|
||||||
await this._connect();
|
await this._connect();
|
||||||
const rtt = this._roundTrip(startAt);
|
const rtt = this._roundTrip(startAt);
|
||||||
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
|
this.stats.histogram('app.hook.connect_time', rtt, ['hook_type:app']);
|
||||||
this.logger.error({retryCount}, 'WsRequestor:request - connection successful, exiting retry loop');
|
|
||||||
lastError = null;
|
lastError = null;
|
||||||
break;
|
break;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error;
|
lastError = error;
|
||||||
retryCount++;
|
retryCount++;
|
||||||
this.logger.error({error: error.message, retryCount, maxReconnects: this.maxReconnects},
|
|
||||||
'WsRequestor:request - connection attempt failed');
|
|
||||||
|
|
||||||
if (retryCount <= this.maxReconnects &&
|
if (retryCount <= this.maxReconnects &&
|
||||||
this.retryPolicyValues?.length &&
|
this.retryPolicyValues?.length &&
|
||||||
this._shouldRetry(error, this.retryPolicyValues)) {
|
this._shouldRetry(error, this.retryPolicyValues)) {
|
||||||
|
|
||||||
this.logger.error(
|
|
||||||
{url, error, retryCount, maxRetries: this.maxReconnects},
|
|
||||||
`WsRequestor:request - connection failed, retrying (${retryCount}/${this.maxReconnects})`
|
|
||||||
);
|
|
||||||
|
|
||||||
const delay = this.backoffMs;
|
const delay = this.backoffMs;
|
||||||
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
this.backoffMs = this.backoffMs < 2000 ? this.backoffMs * 2 : (this.backoffMs + 2000);
|
||||||
this.logger.error({delay}, 'WsRequestor:request - waiting before retry');
|
this.logger.debug({delay}, 'WsRequestor:request - waiting before retry');
|
||||||
await new Promise((resolve) => setTimeout(resolve, delay));
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
||||||
this.logger.error('WsRequestor:request - retry delay complete, attempting retry');
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
this.logger.error({lastError: lastError.message, retryCount, maxReconnects: this.maxReconnects},
|
|
||||||
'WsRequestor:request - throwing last error');
|
this.logger.error({error: error.message, retryCount, maxReconnects: this.maxReconnects},
|
||||||
|
'WsRequestor:request - all connection attempts failed');
|
||||||
throw lastError;
|
throw lastError;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -370,7 +361,7 @@ class WsRequestor extends BaseRequestor {
|
|||||||
|
|
||||||
this
|
this
|
||||||
.once('ready', (ws) => {
|
.once('ready', (ws) => {
|
||||||
this.logger.error({retryCount: 'unknown'}, 'WsRequestor:_connect - ready event fired, resolving Promise');
|
this.logger.debug('WsRequestor:_connect - ready event fired, resolving Promise');
|
||||||
this.removeAllListeners('not-ready');
|
this.removeAllListeners('not-ready');
|
||||||
if (this.connections > 1) this.request('session:reconnect', this.url);
|
if (this.connections > 1) this.request('session:reconnect', this.url);
|
||||||
resolve();
|
resolve();
|
||||||
|
|||||||
816
package-lock.json
generated
816
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,10 @@
|
|||||||
"@jambonz/http-health-check": "^0.0.1",
|
"@jambonz/http-health-check": "^0.0.1",
|
||||||
"@jambonz/mw-registrar": "^0.2.7",
|
"@jambonz/mw-registrar": "^0.2.7",
|
||||||
"@jambonz/realtimedb-helpers": "^0.8.13",
|
"@jambonz/realtimedb-helpers": "^0.8.13",
|
||||||
"@jambonz/speech-utils": "^0.2.11",
|
"@jambonz/speech-utils": "^0.2.15",
|
||||||
"@jambonz/stats-collector": "^0.1.10",
|
"@jambonz/stats-collector": "^0.1.10",
|
||||||
"@jambonz/time-series": "^0.2.13",
|
"@jambonz/time-series": "^0.2.14",
|
||||||
"@jambonz/verb-specifications": "^0.0.104",
|
"@jambonz/verb-specifications": "^0.0.108",
|
||||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||||
"@opentelemetry/api": "^1.8.0",
|
"@opentelemetry/api": "^1.8.0",
|
||||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||||
@@ -48,7 +48,7 @@
|
|||||||
"bent": "^7.3.12",
|
"bent": "^7.3.12",
|
||||||
"debug": "^4.3.4",
|
"debug": "^4.3.4",
|
||||||
"deepcopy": "^2.1.0",
|
"deepcopy": "^2.1.0",
|
||||||
"drachtio-fsmrf": "^4.0.3",
|
"drachtio-fsmrf": "^4.0.4",
|
||||||
"drachtio-srf": "^5.0.5",
|
"drachtio-srf": "^5.0.5",
|
||||||
"express": "^4.19.2",
|
"express": "^4.19.2",
|
||||||
"express-validator": "^7.0.1",
|
"express-validator": "^7.0.1",
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ require('./sip-request-tests');
|
|||||||
require('./create-call-test');
|
require('./create-call-test');
|
||||||
require('./play-tests');
|
require('./play-tests');
|
||||||
require('./sip-refer-tests');
|
require('./sip-refer-tests');
|
||||||
|
require('./sip-refer-handler-tests');
|
||||||
require('./listen-tests');
|
require('./listen-tests');
|
||||||
require('./config-test');
|
require('./config-test');
|
||||||
require('./queue-test');
|
require('./queue-test');
|
||||||
|
|||||||
117
test/scenarios/uas-dial-refer.xml
Normal file
117
test/scenarios/uas-dial-refer.xml
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
<?xml version="1.0" encoding="ISO-8859-1" ?>
|
||||||
|
<!DOCTYPE scenario SYSTEM "sipp.dtd">
|
||||||
|
|
||||||
|
<scenario name="UAS that accepts call and sends REFER">
|
||||||
|
<!-- Receive incoming INVITE -->
|
||||||
|
<recv request="INVITE" crlf="true">
|
||||||
|
<action>
|
||||||
|
<ereg regexp=".*" search_in="hdr" header="Subject:" assign_to="1" />
|
||||||
|
<ereg regexp=".*" search_in="hdr" header="From:" assign_to="2" />
|
||||||
|
</action>
|
||||||
|
</recv>
|
||||||
|
|
||||||
|
<!-- Send 180 Ringing -->
|
||||||
|
<send>
|
||||||
|
<![CDATA[
|
||||||
|
SIP/2.0 180 Ringing
|
||||||
|
[last_Via:]
|
||||||
|
[last_From:]
|
||||||
|
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||||
|
[last_Call-ID:]
|
||||||
|
[last_CSeq:]
|
||||||
|
[last_Record-Route:]
|
||||||
|
Subject:[$1]
|
||||||
|
Content-Length: 0
|
||||||
|
]]>
|
||||||
|
</send>
|
||||||
|
|
||||||
|
<!-- Send 200 OK with SDP -->
|
||||||
|
<send>
|
||||||
|
<![CDATA[
|
||||||
|
SIP/2.0 200 OK
|
||||||
|
[last_Via:]
|
||||||
|
[last_From:]
|
||||||
|
[last_To:];tag=[pid]SIPpTag01[call_number]
|
||||||
|
[last_Call-ID:]
|
||||||
|
[last_CSeq:]
|
||||||
|
[last_Record-Route:]
|
||||||
|
Subject:[$1]
|
||||||
|
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||||
|
Content-Type: application/sdp
|
||||||
|
Content-Length: [len]
|
||||||
|
|
||||||
|
v=0
|
||||||
|
o=user1 53655765 2353687637 IN IP[local_ip_type] [local_ip]
|
||||||
|
s=-
|
||||||
|
c=IN IP[media_ip_type] [media_ip]
|
||||||
|
t=0 0
|
||||||
|
m=audio [media_port] RTP/AVP 0
|
||||||
|
a=rtpmap:0 PCMU/8000
|
||||||
|
]]>
|
||||||
|
</send>
|
||||||
|
|
||||||
|
<recv request="ACK" rtd="true" crlf="true">
|
||||||
|
<action>
|
||||||
|
<!-- Check if this is NOT the first call (tag ends with 012 or higher) -->
|
||||||
|
<ereg regexp="tag=1SIPpTag01[2-9]" search_in="hdr" header="To:" assign_to="3" />
|
||||||
|
<log message="Not first call check result: [$3]"/>
|
||||||
|
</action>
|
||||||
|
</recv>
|
||||||
|
|
||||||
|
<!-- Skip REFER if we found a non-first call tag -->
|
||||||
|
<nop next="skip_refer" test="3" value="" compare="not_equal">
|
||||||
|
<action>
|
||||||
|
<log message="Found non-first call tag [$3], skipping REFER"/>
|
||||||
|
</action>
|
||||||
|
</nop>
|
||||||
|
|
||||||
|
<!-- Wait a moment, then send REFER (only on first call) -->
|
||||||
|
<pause milliseconds="1000"/>
|
||||||
|
|
||||||
|
<nop>
|
||||||
|
<action>
|
||||||
|
<log message="Sending REFER for first call"/>
|
||||||
|
</action>
|
||||||
|
</nop>
|
||||||
|
|
||||||
|
<!-- Send REFER (only on first iteration) -->
|
||||||
|
<send retrans="500">
|
||||||
|
<![CDATA[
|
||||||
|
REFER sip:service@[remote_ip]:[remote_port] SIP/2.0
|
||||||
|
Via: SIP/2.0/[transport] [local_ip]:[local_port];branch=[branch]
|
||||||
|
From: <sip:[local_ip]:[local_port]>;tag=[pid]SIPpTag01[call_number]
|
||||||
|
To: [$2]
|
||||||
|
[last_Call-ID:]
|
||||||
|
CSeq: 2 REFER
|
||||||
|
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||||
|
Max-Forwards: 70
|
||||||
|
X-Call-Number: [call_number]
|
||||||
|
Refer-To: <sip:+15551234567@example.com>
|
||||||
|
Referred-By: <sip:[local_ip]:[local_port]>
|
||||||
|
Content-Length: 0
|
||||||
|
]]>
|
||||||
|
</send>
|
||||||
|
|
||||||
|
<!-- Expect 202 Accepted (only on first iteration) -->
|
||||||
|
<recv response="202"/>
|
||||||
|
|
||||||
|
<label id="skip_refer"/>
|
||||||
|
|
||||||
|
<!-- Wait for BYE from feature server -->
|
||||||
|
<recv request="BYE"/>
|
||||||
|
|
||||||
|
<!-- Send 200 OK to BYE -->
|
||||||
|
<send>
|
||||||
|
<![CDATA[
|
||||||
|
SIP/2.0 200 OK
|
||||||
|
[last_Via:]
|
||||||
|
[last_From:]
|
||||||
|
[last_To:]
|
||||||
|
[last_Call-ID:]
|
||||||
|
[last_CSeq:]
|
||||||
|
Contact: <sip:[local_ip]:[local_port];transport=[transport]>
|
||||||
|
Content-Length: 0
|
||||||
|
]]>
|
||||||
|
</send>
|
||||||
|
|
||||||
|
</scenario>
|
||||||
90
test/sip-refer-handler-tests.js
Normal file
90
test/sip-refer-handler-tests.js
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
const test = require('tape');
|
||||||
|
const { sippUac } = require('./sipp')('test_fs');
|
||||||
|
const bent = require('bent');
|
||||||
|
const getJSON = bent('json')
|
||||||
|
const clearModule = require('clear-module');
|
||||||
|
const {provisionCallHook} = require('./utils');
|
||||||
|
const { sleepFor } = require('../lib/utils/helpers');
|
||||||
|
|
||||||
|
process.on('unhandledRejection', (reason, p) => {
|
||||||
|
console.log('Unhandled Rejection at: Promise', p, 'reason:', reason);
|
||||||
|
});
|
||||||
|
|
||||||
|
function connect(connectable) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
connectable.on('connect', () => {
|
||||||
|
return resolve();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
test('when parent leg recvs REFER it should end the dial after adulting child leg', async(t) => {
|
||||||
|
clearModule.all();
|
||||||
|
const {srf, disconnect} = require('../app');
|
||||||
|
try {
|
||||||
|
await connect(srf);
|
||||||
|
// wait for fs connected to drachtio server.
|
||||||
|
await sleepFor(1000);
|
||||||
|
|
||||||
|
// GIVEN
|
||||||
|
const from = "dial_refer_handler";
|
||||||
|
let verbs = [
|
||||||
|
{
|
||||||
|
"verb": "dial",
|
||||||
|
"callerId": from,
|
||||||
|
"actionHook": "/actionHook",
|
||||||
|
"referHook": "/referHook",
|
||||||
|
"anchorMedia": true,
|
||||||
|
"target": [
|
||||||
|
{
|
||||||
|
"type": "phone",
|
||||||
|
"number": "15083084809"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
await provisionCallHook(from, verbs);
|
||||||
|
|
||||||
|
// THEN
|
||||||
|
//const p = sippUac('uas-dial.xml', '172.38.0.10', undefined, undefined, 2);
|
||||||
|
const p = sippUac('uas-dial-refer.xml', '172.38.0.10', undefined, undefined, 2);
|
||||||
|
await sleepFor(1000);
|
||||||
|
|
||||||
|
let account_sid = '622f62e4-303a-49f2-bbe0-eb1e1714e37a';
|
||||||
|
|
||||||
|
let post = bent('http://127.0.0.1:3000/', 'POST', 'json', 201);
|
||||||
|
post('v1/createCall', {
|
||||||
|
'account_sid':account_sid,
|
||||||
|
"call_hook": {
|
||||||
|
"url": "http://127.0.0.1:3100/",
|
||||||
|
"method": "POST",
|
||||||
|
},
|
||||||
|
"from": from,
|
||||||
|
"to": {
|
||||||
|
"type": "phone",
|
||||||
|
"number": "15583084808"
|
||||||
|
}});
|
||||||
|
|
||||||
|
await p;
|
||||||
|
|
||||||
|
// Verify that the referHook was called
|
||||||
|
const obj = await getJSON(`http://127.0.0.1:3100/lastRequest/${from}_referHook`);
|
||||||
|
t.ok(obj.body.from === from,
|
||||||
|
'dial-refer-handler: referHook was called with correct from');
|
||||||
|
t.ok(obj.body.refer_details && obj.body.refer_details.sip_refer_to,
|
||||||
|
'dial-refer-handler: refer_details included in referHook');
|
||||||
|
t.ok(obj.body.refer_details.refer_to_user === '+15551234567',
|
||||||
|
'dial-refer-handler: refer_to_user correctly parsed');
|
||||||
|
t.ok(obj.body.refer_details.referring_call_sid,
|
||||||
|
'dial-refer-handler: referring_call_sid included');
|
||||||
|
t.ok(obj.body.refer_details.referred_call_sid,
|
||||||
|
'dial-refer-handler: referred_call_sid included');
|
||||||
|
|
||||||
|
disconnect();
|
||||||
|
} catch (err) {
|
||||||
|
console.log(`error received: ${err}`);
|
||||||
|
disconnect();
|
||||||
|
t.error(err);
|
||||||
|
}
|
||||||
|
});
|
||||||
@@ -99,6 +99,24 @@ app.post('/actionHook', (req, res) => {
|
|||||||
return res.sendStatus(200);
|
return res.sendStatus(200);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* referHook
|
||||||
|
*/
|
||||||
|
app.post('/referHook', (req, res) => {
|
||||||
|
console.log({payload: req.body}, 'POST /referHook');
|
||||||
|
let key = req.body.from + "_referHook"
|
||||||
|
addRequestToMap(key, req, hook_mapping);
|
||||||
|
return res.json([{"verb": "pause", "length": 2}]);
|
||||||
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
* adultingHook
|
||||||
|
*/
|
||||||
|
app.post('/adulting', (req, res) => {
|
||||||
|
console.log({payload: req.body}, 'POST /adulting');
|
||||||
|
return res.sendStatus(200);
|
||||||
|
});
|
||||||
|
|
||||||
/*
|
/*
|
||||||
* customHook
|
* customHook
|
||||||
* For the hook to return
|
* For the hook to return
|
||||||
|
|||||||
Reference in New Issue
Block a user