mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-11 00:39:56 +00:00
Compare commits
8 Commits
v0.9.5-rc1
...
v0.9.5-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
62b6a814b7 | ||
|
|
e415420150 | ||
|
|
e6e039e0f2 | ||
|
|
657e2d4a49 | ||
|
|
337c1cded0 | ||
|
|
444abcb036 | ||
|
|
c82a835e70 | ||
|
|
3c185d4bd2 |
@@ -17,6 +17,7 @@ const { mergeSdpMedia, extractSdpMedia, removeVideoSdp } = require('../../utils/
|
||||
const { createCallSchema, customSanitizeFunction } = require('../schemas/create-call');
|
||||
const { selectHostPort } = require('../../utils/network');
|
||||
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 removeNulls = (req, res, next) => {
|
||||
@@ -67,7 +68,7 @@ router.post('/',
|
||||
const {
|
||||
lookupAppBySid
|
||||
} = srf.locals.dbHelpers;
|
||||
const {getSBC, getFreeswitch} = srf.locals;
|
||||
const {getSBC} = srf.locals;
|
||||
let sbcAddress = getSBC();
|
||||
if (!sbcAddress) throw new Error('no available SBCs for outbound call creation');
|
||||
const target = restDial.to;
|
||||
@@ -170,9 +171,7 @@ router.post('/',
|
||||
}
|
||||
|
||||
/* create endpoint for outdial */
|
||||
const ms = getFreeswitch();
|
||||
if (!ms) throw new Error('no available Freeswitch for outbound call creation');
|
||||
const ep = await ms.createEndpoint();
|
||||
const ep = await createMediaEndpoint(srf, logger);
|
||||
logger.debug(`createCall: successfully allocated endpoint, sending INVITE to ${sbcAddress}`);
|
||||
|
||||
/* launch outdial */
|
||||
@@ -181,7 +180,7 @@ router.post('/',
|
||||
let localSdp = ep.local.sdp;
|
||||
|
||||
if (req.body.dual_streams) {
|
||||
dualEp = await ms.createEndpoint();
|
||||
dualEp = await createMediaEndpoint(srf, logger);
|
||||
localSdp = mergeSdpMedia(localSdp, dualEp.local.sdp);
|
||||
}
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS) {
|
||||
@@ -239,6 +238,7 @@ router.post('/',
|
||||
if (app.call_hook.url === app.call_status_hook?.url || !app.call_status_hook?.url) {
|
||||
logger.debug('reusing websocket for call status hook');
|
||||
app.notifier = app.requestor;
|
||||
app.call_status_hook = app.call_hook;
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -29,10 +29,6 @@ const {
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
|
||||
AWS_REGION,
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
|
||||
} = require('../config');
|
||||
const bent = require('bent');
|
||||
const BackgroundTaskManager = require('../utils/background-task-manager');
|
||||
@@ -40,7 +36,7 @@ const dbUtils = require('../utils/db-utils');
|
||||
const BADPRECONDITIONS = 'preconditions not met';
|
||||
const CALLER_CANCELLED_ERR_MSG = 'Response not sent due to unknown transaction';
|
||||
const { NonFatalTaskError} = require('../utils/error');
|
||||
const { sleepFor } = require('../utils/helpers');
|
||||
const { createMediaEndpoint } = require('../utils/media-endpoint');
|
||||
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
|
||||
WHERE webhook_sid =
|
||||
(
|
||||
@@ -1430,6 +1426,14 @@ class CallSession extends Emitter {
|
||||
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}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2310,8 +2314,7 @@ Duration=${duration} `
|
||||
|
||||
// need to allocate an endpoint
|
||||
try {
|
||||
if (!this.ms) this.ms = this.getMS();
|
||||
const ep = await this.ms.createEndpoint({
|
||||
const ep = await this._createMediaEndpoint({
|
||||
headers: {
|
||||
'X-Jambones-Call-ID': this.callId,
|
||||
},
|
||||
@@ -2320,7 +2323,6 @@ Duration=${duration} `
|
||||
//ep.cs = this;
|
||||
this.ep = ep;
|
||||
this.logger.info(`allocated endpoint ${ep.uuid}`);
|
||||
this._configMsEndpoint();
|
||||
|
||||
this.ep.on('destroy', () => {
|
||||
this.logger.debug(`endpoint was destroyed!! ${this.ep.uuid}`);
|
||||
@@ -2391,9 +2393,6 @@ Duration=${duration} `
|
||||
this.logger.error('CallSession:replaceEndpoint cannot be called without stable dlg');
|
||||
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.
|
||||
if (this.ep?.connected) this.ep.destroy();
|
||||
|
||||
@@ -2418,8 +2417,7 @@ Duration=${duration} `
|
||||
* This prevents call failures during media renegotiation.
|
||||
*/
|
||||
|
||||
this.ep = await this.ms.createEndpoint();
|
||||
this._configMsEndpoint();
|
||||
this.ep = await this._createMediaEndpoint();
|
||||
|
||||
const sdp = await this.dlg.modify(this.ep.local.sdp);
|
||||
await this.ep.modify(sdp);
|
||||
@@ -2679,15 +2677,8 @@ Duration=${duration} `
|
||||
async createOrRetrieveEpAndMs() {
|
||||
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) {
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.req.body});
|
||||
this._configMsEndpoint();
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp: this.req.body});
|
||||
}
|
||||
return {ms: this.ms, ep: this.ep};
|
||||
}
|
||||
@@ -2840,8 +2831,7 @@ Duration=${duration} `
|
||||
async reAnchorMedia(currentMediaRoute = MediaPath.PartialMedia) {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this._configMsEndpoint();
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
await this.dlg.modify(this.ep.local.sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'anchor-media'
|
||||
@@ -2916,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) {
|
||||
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._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
|
||||
*/
|
||||
@@ -3020,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) {
|
||||
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
|
||||
this.logger.debug({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||
@@ -3127,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) {
|
||||
const turns = this.conversationTurns.slice(-numTurns);
|
||||
if (turns.length === 0) return null;
|
||||
|
||||
@@ -27,10 +27,13 @@ class RestCallSession extends CallSession {
|
||||
}
|
||||
|
||||
this.on('callStatusChange', this._notifyCallStatusChange.bind(this));
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
|
||||
setImmediate(() => {
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Trying,
|
||||
sipStatus: 100,
|
||||
sipReason: 'Trying'
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -45,12 +45,11 @@ class SipRecCallSession extends InboundCallSession {
|
||||
|
||||
async answerSipRecCall() {
|
||||
try {
|
||||
this.ms = this.getMS();
|
||||
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');
|
||||
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');
|
||||
await this.ep.bridge(this.ep2);
|
||||
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;
|
||||
@@ -397,7 +397,11 @@ class TaskDial extends Task {
|
||||
...customHeaders
|
||||
}
|
||||
}, 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 {
|
||||
const logger = isChild ? this.logger : this.sd.logger;
|
||||
const tasks = normalizeJambones(logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
@@ -415,21 +419,20 @@ class TaskDial extends Task {
|
||||
/* need to update the callSid of the child with its own (new) AdultingCallSession */
|
||||
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) {
|
||||
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
|
||||
//that makes freeswitch release endpoint for caller leg.
|
||||
if (this.ep) this.ep.unbridge();
|
||||
res.send(202);
|
||||
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
||||
|
||||
/* if we got the REFER on the parent leg, end the dial task after completing the refer */
|
||||
if (!isChild) {
|
||||
this.logger.info('DialTask:handleRefer - killing dial task after processing REFER on parent leg');
|
||||
cs.currentTask?.kill(cs, KillReason.ReferComplete);
|
||||
else {
|
||||
this.logger.info('DialTask:handleRefer - no tasks returned from referHook, not setting new application');
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
|
||||
|
||||
@@ -844,6 +844,10 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_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
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
@@ -1092,7 +1096,8 @@ class TaskGather extends SttTask {
|
||||
if (this.canFallback) {
|
||||
ep.stopTranscription({
|
||||
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}`));
|
||||
try {
|
||||
|
||||
@@ -96,6 +96,9 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.Tag:
|
||||
const TaskTag = require('./tag');
|
||||
return new TaskTag(logger, data, parent);
|
||||
case TaskName.Alert:
|
||||
const TaskAlert = require('./alert');
|
||||
return new TaskAlert(logger, data, parent);
|
||||
}
|
||||
|
||||
// should never reach
|
||||
|
||||
@@ -17,28 +17,33 @@ class TaskRedirect extends Task {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
|
||||
if (cs.requestor instanceof WsRequestor && cs.application.requestor._isAbsoluteUrl(this.actionHook)) {
|
||||
this.logger.info(`Task:performAction redirecting to ${this.actionHook}, requires new ws connection`);
|
||||
try {
|
||||
this.cs.requestor.close();
|
||||
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook}, this.webhook_secret) ;
|
||||
this.cs.application.requestor = requestor;
|
||||
} 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) {
|
||||
const isAbsoluteUrl = cs.application?.requestor?._isAbsoluteUrl(this.actionHook);
|
||||
|
||||
if (isAbsoluteUrl) {
|
||||
this.logger.info(`TaskRedirect redirecting to new absolute URL ${this.actionHook}, requires new requestor`);
|
||||
|
||||
if (cs.requestor instanceof WsRequestor) {
|
||||
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);
|
||||
this.cs.requestor.removeAllListeners();
|
||||
this.cs.application.requestor = newRequestor;
|
||||
const requestor = new WsRequestor(this.logger, cs.accountSid, {url: this.actionHook},
|
||||
cs.accountInfo.account.webhook_secret) ;
|
||||
cs.requestor.emit('handover', requestor);
|
||||
} 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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
147
lib/tasks/say.js
147
lib/tasks/say.js
@@ -247,6 +247,7 @@ class TaskSay extends TtsTask {
|
||||
throw new SpeechCredentialError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
let filepath;
|
||||
try {
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
||||
@@ -263,65 +264,113 @@ class TaskSay extends TtsTask {
|
||||
await this.playToConfMember(ep, memberId, confName, confUuid, filepath[segment]);
|
||||
}
|
||||
else {
|
||||
let playbackId;
|
||||
const isStreaming = filepath[segment].startsWith('say:{');
|
||||
if (isStreaming) {
|
||||
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
||||
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) => {
|
||||
this.logger.debug({evt}, 'Say got playback-start');
|
||||
if (this.otelSpan) {
|
||||
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
if (evt.variable_tts_cache_filename) {
|
||||
cs.trackTmpFile(evt.variable_tts_cache_filename);
|
||||
try {
|
||||
playbackId = evt.variable_tts_playback_id;
|
||||
this.logger.debug({evt},
|
||||
`Say got playback-start ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
|
||||
if (this.otelSpan) {
|
||||
this._addStreamingTtsAttributes(this.otelSpan, evt, vendor);
|
||||
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) => {
|
||||
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;
|
||||
}
|
||||
}
|
||||
ep.once('playback-stop', this._boundOnPlaybackStop);
|
||||
|
||||
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
|
||||
this._playPromise = new Promise((resolve, reject) => {
|
||||
this._playResolve = resolve;
|
||||
|
||||
@@ -137,13 +137,18 @@ class TaskTranscribe extends SttTask {
|
||||
stopTranscription = true;
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
bugname: this.bugname,
|
||||
gracefulShutdown: this.paused ? false : true
|
||||
})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
if (this.transcribing2 && this.ep2?.connected) {
|
||||
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'));
|
||||
}
|
||||
|
||||
@@ -421,6 +426,10 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
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
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
@@ -703,7 +712,8 @@ class TaskTranscribe extends SttTask {
|
||||
if (this.canFallback) {
|
||||
_ep.stopTranscription({
|
||||
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}`));
|
||||
try {
|
||||
|
||||
@@ -418,7 +418,11 @@ module.exports = (logger) => {
|
||||
}
|
||||
|
||||
if (ep.connected) {
|
||||
ep.stopTranscription({vendor, bugname})
|
||||
ep.stopTranscription({
|
||||
vendor,
|
||||
bugname,
|
||||
gracefulShutdown: false
|
||||
})
|
||||
.catch((err) => logger.info(err, 'stopAmd: Error stopping transcription'));
|
||||
task.emit('amd', {type: AmdEvents.Stopped});
|
||||
ep.execute('avmd_stop').catch((err) => this.logger.info(err, 'Error stopping avmd'));
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Alert": "alert",
|
||||
"Answer": "answer",
|
||||
"Conference": "conference",
|
||||
"Config": "config",
|
||||
|
||||
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 WsRequestor = require('./ws-requestor');
|
||||
const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils');
|
||||
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 } = require('./media-endpoint');
|
||||
|
||||
class SingleDialer extends Emitter {
|
||||
constructor({logger, sbcAddress, target, opts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask,
|
||||
@@ -98,6 +92,7 @@ class SingleDialer extends Emitter {
|
||||
};
|
||||
}
|
||||
this.ms = ms;
|
||||
this.srf = srf;
|
||||
let uri, to, inviteSpan;
|
||||
try {
|
||||
switch (this.target.type) {
|
||||
@@ -139,8 +134,7 @@ class SingleDialer extends Emitter {
|
||||
this.updateCallStatus = srf.locals.dbHelpers.updateCallStatus;
|
||||
this.serviceUrl = srf.locals.serviceUrl;
|
||||
|
||||
this.ep = await ms.createEndpoint();
|
||||
this._configMsEndpoint();
|
||||
this.ep = await this._createMediaEndpoint();
|
||||
this.logger.debug(`SingleDialer:exec - created endpoint ${this.ep.uuid}`);
|
||||
|
||||
/**
|
||||
@@ -352,43 +346,19 @@ class SingleDialer extends Emitter {
|
||||
}
|
||||
}
|
||||
|
||||
_configMsEndpoint() {
|
||||
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);
|
||||
}
|
||||
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');
|
||||
}
|
||||
}
|
||||
async _handleMediaTimeout(evt, ep) {
|
||||
this.logger.info({evt}, 'SingleDialer:_handleMediaTimeout - media timeout event received');
|
||||
this.dialTask.kill(this.dialTask.cs, 'media-timeout');
|
||||
}
|
||||
|
||||
const origDestroy = this.ep.destroy.bind(this.ep);
|
||||
this.ep.destroy = async() => {
|
||||
try {
|
||||
if (this.dialTask.transcribeTask && 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.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');
|
||||
}
|
||||
};
|
||||
async _createMediaEndpoint(drachtioFsmrfOptions = {}) {
|
||||
return await createMediaEndpoint(this.srf, this.logger, {
|
||||
acactiveMs: this.ms,
|
||||
drachtioFsmrfOptions,
|
||||
onHoldMusic: this.onHoldMusic,
|
||||
inbandDtmfEnabled: this.dialTask?.inbandDtmfEnabled,
|
||||
mediaTimeoutHandler: this._handleMediaTimeout.bind(this),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -528,8 +498,7 @@ class SingleDialer extends Emitter {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
|
||||
this.logger.debug('SingleDialer:reAnchorMedia: re-anchoring media after partial media');
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this._configMsEndpoint();
|
||||
this.ep = await this._createMediaEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
await this.dlg.modify(this.ep.local.sdp, {
|
||||
headers: {
|
||||
'X-Reason': 'anchor-media'
|
||||
|
||||
@@ -1031,24 +1031,24 @@ module.exports = (logger) => {
|
||||
else if ('assemblyai' === vendor) {
|
||||
const serviceVersion = rOpts.assemblyAiOptions?.serviceVersion || sttCredentials.service_version || 'v2';
|
||||
const {
|
||||
format_turns,
|
||||
end_of_turn_confidence_threshold,
|
||||
min_end_of_turn_silence_when_confident,
|
||||
max_turn_silence
|
||||
formatTurns,
|
||||
endOfTurnConfidenceThreshold,
|
||||
minEndOfTurnSilenceWhenConfident,
|
||||
maxTurnSilence
|
||||
} = rOpts.assemblyAiOptions || {};
|
||||
opts = {
|
||||
...opts,
|
||||
ASSEMBLYAI_API_VERSION: serviceVersion,
|
||||
...(serviceVersion === 'v3' && {
|
||||
...(format_turns && {
|
||||
ASSEMBLYAI_FORMAT_TURNS: format_turns
|
||||
...(formatTurns && {
|
||||
ASSEMBLYAI_FORMAT_TURNS: formatTurns
|
||||
}),
|
||||
...(end_of_turn_confidence_threshold && {
|
||||
ASSEMBLYAI_END_OF_TURN_CONFIDENCE_THRESHOLD: end_of_turn_confidence_threshold
|
||||
...(endOfTurnConfidenceThreshold && {
|
||||
ASSEMBLYAI_END_OF_TURN_CONFIDENCE_THRESHOLD: endOfTurnConfidenceThreshold
|
||||
}),
|
||||
ASSEMBLYAI_MIN_END_OF_TURN_SILENCE_WHEN_CONFIDENT: min_end_of_turn_silence_when_confident || 500,
|
||||
...(max_turn_silence && {
|
||||
ASSEMBLYAI_MAX_TURN_SILENCE: max_turn_silence
|
||||
ASSEMBLYAI_MIN_END_OF_TURN_SILENCE_WHEN_CONFIDENT: minEndOfTurnSilenceWhenConfident || 500,
|
||||
...(maxTurnSilence && {
|
||||
ASSEMBLYAI_MAX_TURN_SILENCE: maxTurnSilence
|
||||
}),
|
||||
}),
|
||||
...(sttCredentials.api_key) &&
|
||||
|
||||
779
package-lock.json
generated
779
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/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.13",
|
||||
"@jambonz/speech-utils": "^0.2.13",
|
||||
"@jambonz/speech-utils": "^0.2.15",
|
||||
"@jambonz/stats-collector": "^0.1.10",
|
||||
"@jambonz/time-series": "^0.2.13",
|
||||
"@jambonz/verb-specifications": "^0.0.106",
|
||||
"@jambonz/time-series": "^0.2.14",
|
||||
"@jambonz/verb-specifications": "^0.0.108",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
|
||||
Reference in New Issue
Block a user