mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
51 Commits
feat/aws_s
...
logging
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a4726cbf1d | ||
|
|
2800c4cc4c | ||
|
|
b4b096d07c | ||
|
|
10b5ceeef1 | ||
|
|
ea954eca0b | ||
|
|
d93da88bff | ||
|
|
10fb6b1e67 | ||
|
|
654ccd9d9d | ||
|
|
ea27b20ac5 | ||
|
|
96aa705378 | ||
|
|
5e51849839 | ||
|
|
44f69fa76d | ||
|
|
73c77bea71 | ||
|
|
babc0d0dbb | ||
|
|
6b8d0fe1a8 | ||
|
|
66bb466297 | ||
|
|
1933f4ec0b | ||
|
|
b1089a1ae9 | ||
|
|
93e06d887e | ||
|
|
b478e0ecd2 | ||
|
|
94d43d4b70 | ||
|
|
eb449e9169 | ||
|
|
158d9d7d25 | ||
|
|
5886d1d945 | ||
|
|
352106ec0c | ||
|
|
05a6bf51a7 | ||
|
|
bd1c763e72 | ||
|
|
d831a4ca7f | ||
|
|
0cc6ea987f | ||
|
|
6e7521c91c | ||
|
|
e0e2ade289 | ||
|
|
ee895b4046 | ||
|
|
2a42ccb0e1 | ||
|
|
62b6a814b7 | ||
|
|
e415420150 | ||
|
|
e6e039e0f2 | ||
|
|
657e2d4a49 | ||
|
|
337c1cded0 | ||
|
|
444abcb036 | ||
|
|
c82a835e70 | ||
|
|
3c185d4bd2 | ||
|
|
ba2049b705 | ||
|
|
7691af30de | ||
|
|
ab83b21979 | ||
|
|
f18b62e165 | ||
|
|
f98bf2a1f8 | ||
|
|
8c67c05d87 | ||
|
|
3f11ee58a7 | ||
|
|
c8d94026ff | ||
|
|
5be6c54339 | ||
|
|
259dedcded |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -2,6 +2,9 @@
|
||||
logs
|
||||
*.log
|
||||
|
||||
.claude/
|
||||
CLAUDE.md
|
||||
|
||||
# Runtime data
|
||||
pids
|
||||
*.pid
|
||||
|
||||
6
app.js
6
app.js
@@ -29,6 +29,12 @@ const {LifeCycleEvents, FS_UUID_SET_NAME, SystemState, FEATURE_SERVER} = require
|
||||
const installSrfLocals = require('./lib/utils/install-srf-locals');
|
||||
const createHttpListener = require('./lib/utils/http-listener');
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
const ProcessMonitor = require('./lib/utils/process-monitor');
|
||||
const monitor = new ProcessMonitor(logger);
|
||||
|
||||
// Log startup
|
||||
monitor.logStartup();
|
||||
monitor.setupSignalHandlers();
|
||||
|
||||
logger.on('level-change', (lvl, _val, prevLvl, _prevVal, instance) => {
|
||||
if (logger !== instance) {
|
||||
|
||||
@@ -130,7 +130,7 @@ const OPTIONS_PING_INTERVAL = parseInt(process.env.OPTIONS_PING_INTERVAL, 10) ||
|
||||
const JAMBONZ_RECORD_WS_BASE_URL = process.env.JAMBONZ_RECORD_WS_BASE_URL || process.env.JAMBONES_RECORD_WS_BASE_URL;
|
||||
const JAMBONZ_RECORD_WS_USERNAME = process.env.JAMBONZ_RECORD_WS_USERNAME || process.env.JAMBONES_RECORD_WS_USERNAME;
|
||||
const JAMBONZ_RECORD_WS_PASSWORD = process.env.JAMBONZ_RECORD_WS_PASSWORD || process.env.JAMBONES_RECORD_WS_PASSWORD;
|
||||
const JAMBONZ_DISABLE_DIAL_PAI_HEADER = process.env.JAMBONZ_DISABLE_DIAL_PAI_HEADER || false;
|
||||
const JAMBONZ_DIAL_PAI_HEADER = process.env.JAMBONZ_DIAL_PAI_HEADER || false;
|
||||
const JAMBONES_DISABLE_DIRECT_P2P_CALL = process.env.JAMBONES_DISABLE_DIRECT_P2P_CALL || false;
|
||||
|
||||
const JAMBONES_EAGERLY_PRE_CACHE_AUDIO = parseInt(process.env.JAMBONES_EAGERLY_PRE_CACHE_AUDIO, 10) || 0;
|
||||
@@ -225,11 +225,11 @@ module.exports = {
|
||||
JAMBONZ_RECORD_WS_BASE_URL,
|
||||
JAMBONZ_RECORD_WS_USERNAME,
|
||||
JAMBONZ_RECORD_WS_PASSWORD,
|
||||
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
||||
JAMBONZ_DIAL_PAI_HEADER,
|
||||
JAMBONES_DISABLE_DIRECT_P2P_CALL,
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS
|
||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
|
||||
};
|
||||
|
||||
@@ -13,10 +13,11 @@ const WsRequestor = require('../../utils/ws-requestor');
|
||||
const RootSpan = require('../../utils/call-tracer');
|
||||
const dbUtils = require('../../utils/db-utils');
|
||||
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 { 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,10 +180,14 @@ 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) {
|
||||
logger.debug('createCall: removing video sdp');
|
||||
localSdp = removeVideoSdp(localSdp);
|
||||
ep.modify(localSdp);
|
||||
}
|
||||
const connectStream = async(remoteSdp) => {
|
||||
if (remoteSdp !== sdp) {
|
||||
sdp = remoteSdp;
|
||||
@@ -235,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 {
|
||||
|
||||
@@ -10,8 +10,9 @@ const {
|
||||
RecordState,
|
||||
AllowedSipRecVerbs,
|
||||
AllowedConfirmSessionVerbs,
|
||||
TtsStreamingEvents
|
||||
} = require('../utils/constants');
|
||||
TtsStreamingEvents,
|
||||
ListenStatus
|
||||
} = require('../utils/constants.json');
|
||||
const moment = require('moment');
|
||||
const assert = require('assert');
|
||||
const sessionTracker = require('./session-tracker');
|
||||
@@ -29,10 +30,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 +37,8 @@ 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 SttLatencyCalculator = require('../utils/stt-latency-calculator');
|
||||
const sqlRetrieveQueueEventHook = `SELECT * FROM webhooks
|
||||
WHERE webhook_sid =
|
||||
(
|
||||
@@ -150,6 +148,30 @@ class CallSession extends Emitter {
|
||||
this.conversationTurns = [];
|
||||
this.on('userSaid', this._onUserSaid.bind(this));
|
||||
this.on('botSaid', this._onBotSaid.bind(this));
|
||||
/**
|
||||
* Support STT latency
|
||||
*/
|
||||
this.sttLatencyCalculator = new SttLatencyCalculator({
|
||||
logger,
|
||||
cs: this
|
||||
});
|
||||
this.on('transcribe-start', () => {
|
||||
this.sttLatencyCalculator.resetTime();
|
||||
});
|
||||
this.on('on-transcription', () => {
|
||||
this.sttLatencyCalculator.onTranscriptionReceived();
|
||||
});
|
||||
this.on('transcribe-stop', () => {
|
||||
this.sttLatencyCalculator.onTranscribeStop();
|
||||
});
|
||||
}
|
||||
|
||||
get notifySttLatencyEnabled() {
|
||||
return this._notifySttLatencyEnabled || false;
|
||||
}
|
||||
|
||||
set notifySttLatencyEnabled(enabled) {
|
||||
this._notifySttLatencyEnabled = enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -720,10 +742,25 @@ class CallSession extends Emitter {
|
||||
return this._fillerNoise;
|
||||
}
|
||||
|
||||
async pauseOrResumeBackgroundListenIfRequired(action, silence = false) {
|
||||
if ((action == 'pauseCallRecording' || action == 'resumeCallRecording') &&
|
||||
this.backgroundTaskManager.isTaskRunning('record')) {
|
||||
this.logger.debug({action, silence}, 'CallSession:pauseOrResumeBackgroundListenIfRequired');
|
||||
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
|
||||
const status = action === 'pauseCallRecording' ? ListenStatus.Pause : ListenStatus.Resume;
|
||||
backgroundListenTask.updateListen(
|
||||
status,
|
||||
silence
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async notifyRecordOptions(opts) {
|
||||
const {action} = opts;
|
||||
const {action, silence} = opts;
|
||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||
|
||||
this.pauseOrResumeBackgroundListenIfRequired(action, silence);
|
||||
|
||||
/* if we have not answered yet, just save the details for later */
|
||||
if (!this.dlg) {
|
||||
if (action === 'startCallRecording') {
|
||||
@@ -1097,6 +1134,7 @@ class CallSession extends Emitter {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
stt_model_id: credential.stt_model_id,
|
||||
embedding: credential.embedding,
|
||||
options: credential.options
|
||||
};
|
||||
@@ -1108,10 +1146,31 @@ class CallSession extends Emitter {
|
||||
options: credential.options
|
||||
};
|
||||
}
|
||||
else if ('resemble' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
resemble_tts_use_tls: credential.resemble_tts_use_tls,
|
||||
resemble_tts_uri: credential.resemble_tts_uri,
|
||||
};
|
||||
}
|
||||
else if ('inworld' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
options: credential.options
|
||||
};
|
||||
}
|
||||
else if ('assemblyai' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key
|
||||
api_key: credential.api_key,
|
||||
service_version: credential.service_version
|
||||
};
|
||||
}
|
||||
else if ('deepgramriver' === vendor) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
};
|
||||
}
|
||||
else if ('voxist' === vendor) {
|
||||
@@ -1421,6 +1480,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}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1648,8 +1715,8 @@ Duration=${duration} `
|
||||
async _lccWhisper(opts, callSid) {
|
||||
const {whisper} = opts;
|
||||
let tasks;
|
||||
const b3 = this.b3;
|
||||
const httpHeaders = b3 && {b3};
|
||||
//const b3 = this.b3;
|
||||
//const httpHeaders = b3 && {b3};
|
||||
|
||||
// this whole thing requires us to be in a Dial verb
|
||||
const task = this.currentTask;
|
||||
@@ -1658,12 +1725,15 @@ Duration=${duration} `
|
||||
}
|
||||
|
||||
// allow user to provide a url object, a url string, an array of tasks, or a single task
|
||||
if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url)) {
|
||||
// retrieve a url
|
||||
const json = await this.requestor(opts.call_hook, this.callInfo.toJSON(), httpHeaders);
|
||||
tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
}
|
||||
else if (Array.isArray(whisper)) {
|
||||
// Disable passing a URL as not functional Sam Machin - 17/07/2025
|
||||
//if (typeof whisper === 'string' || (typeof whisper === 'object' && whisper.url && !whisper.verb)) {
|
||||
// // retrieve a url
|
||||
// const json = await this.requestor(opts.call_hook, this.callInfo.toJSON(), httpHeaders);
|
||||
// tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
//}
|
||||
//else if (Array.isArray(whisper)) {
|
||||
|
||||
if (Array.isArray(whisper)) {
|
||||
// an inline array of tasks
|
||||
tasks = normalizeJambones(this.logger, whisper).map((tdata) => makeTask(this.logger, tdata));
|
||||
}
|
||||
@@ -1900,6 +1970,17 @@ Duration=${duration} `
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, opts, callSid}, 'CallSession:updateCall - error updating call');
|
||||
const {writeAlerts} = this.srf.locals;
|
||||
try {
|
||||
writeAlerts({
|
||||
alert_type: 'error-updating-call',
|
||||
account_sid: this.accountSid,
|
||||
message: err.message,
|
||||
target_sid: callSid
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'Error writing error-updating-call alert');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2301,17 +2382,16 @@ 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,
|
||||
'X-Call-Sid': this.callSid,
|
||||
},
|
||||
remoteSdp: this.req.body
|
||||
});
|
||||
//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}`);
|
||||
@@ -2382,9 +2462,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();
|
||||
|
||||
@@ -2409,8 +2486,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);
|
||||
@@ -2454,6 +2530,8 @@ Duration=${duration} `
|
||||
this.clearOrRestoreActionHookDelayProcessor().catch((err) => {});
|
||||
|
||||
this.ttsStreamingBuffer?.stop();
|
||||
|
||||
this.sttLatencyCalculator?.stop();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -2670,15 +2748,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};
|
||||
}
|
||||
@@ -2831,8 +2902,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'
|
||||
@@ -2907,59 +2977,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
|
||||
*/
|
||||
@@ -3011,6 +3035,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');
|
||||
@@ -3044,6 +3080,11 @@ Duration=${duration} `
|
||||
const task = this.currentTask;
|
||||
if (task && TaskName.Say === task.name) {
|
||||
task.notifyTtsStreamIsEmpty();
|
||||
} else if (
|
||||
// If Gather nested say task is streaming
|
||||
TaskName.Gather === task.name && task.sayTask && task.sayTask.isStreamingTts) {
|
||||
const sayTask = task.sayTask;
|
||||
sayTask.notifyTtsStreamIsEmpty();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3118,6 +3159,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;
|
||||
@@ -3128,6 +3179,20 @@ Duration=${duration} `
|
||||
return `assistant: ${t.text}`;
|
||||
}).join('\n');
|
||||
}
|
||||
|
||||
startSttLatencyVad() {
|
||||
if (this.notifySttLatencyEnabled) {
|
||||
this.sttLatencyCalculator.start();
|
||||
}
|
||||
}
|
||||
|
||||
stopSttLatencyVad() {
|
||||
this.sttLatencyCalculator.stop();
|
||||
}
|
||||
|
||||
calculateSttLatency() {
|
||||
return this.sttLatencyCalculator.calculateLatency();
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CallSession;
|
||||
|
||||
@@ -8,7 +8,8 @@ const CallSession = require('./call-session');
|
||||
|
||||
*/
|
||||
class ConfirmCallSession extends CallSession {
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req}) {
|
||||
// eslint-disable-next-line max-len
|
||||
constructor({logger, application, dlg, ep, tasks, callInfo, accountInfo, memberId, confName, rootSpan, req, tmpFiles}) {
|
||||
super({
|
||||
logger,
|
||||
application,
|
||||
@@ -24,6 +25,7 @@ class ConfirmCallSession extends CallSession {
|
||||
this.dlg = dlg;
|
||||
this.ep = ep;
|
||||
this.req = req;
|
||||
this.tmpFiles = tmpFiles;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
@@ -673,7 +673,8 @@ class Conference extends Task {
|
||||
confName: this.confName,
|
||||
tasks,
|
||||
rootSpan: cs.rootSpan,
|
||||
req: cs.req
|
||||
req: cs.req,
|
||||
tmpFiles: cs.tmpFiles,
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
|
||||
@@ -24,6 +24,9 @@ class TaskConfig extends Task {
|
||||
if ('notifyEvents' in this.data) {
|
||||
this.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
if (this.hasNotifySttLatency) {
|
||||
this.notifySttLatency = !!this.data.notifySttLatency;
|
||||
}
|
||||
|
||||
if (this.bargeIn.enable) {
|
||||
this.gatherOpts = {
|
||||
@@ -83,6 +86,7 @@ class TaskConfig extends Task {
|
||||
get hasVad() { return Object.keys(this.vad).length; }
|
||||
get hasFillerNoise() { return Object.keys(this.fillerNoise).length; }
|
||||
get hasReferHook() { return Object.keys(this.data).includes('referHook'); }
|
||||
get hasNotifySttLatency() { return Object.keys(this.data).includes('notifySttLatency'); }
|
||||
get hasTtsStream() { return Object.keys(this.ttsStream).length; }
|
||||
|
||||
get summary() {
|
||||
@@ -112,6 +116,8 @@ class TaskConfig extends Task {
|
||||
if (this.hasFillerNoise) phrase.push(`fillerNoise ${this.fillerNoise.enable ? 'on' : 'off'}`);
|
||||
if (this.data.amd) phrase.push('enable amd');
|
||||
if (this.notifyEvents) phrase.push(`event notification ${this.notifyEvents ? 'on' : 'off'}`);
|
||||
if (this.hasNotifySttLatency) phrase.push(
|
||||
`notifySttLatency ${this.notifySttLatency ? 'on' : 'off'}`);
|
||||
if (this.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`);
|
||||
if ('boostAudioSignal' in this.data) phrase.push(`setGain ${this.data.boostAudioSignal}`);
|
||||
if (this.hasReferHook) phrase.push('set referHook');
|
||||
@@ -130,6 +136,11 @@ class TaskConfig extends Task {
|
||||
cs.notifyEvents = !!this.data.notifyEvents;
|
||||
}
|
||||
|
||||
if (this.hasNotifySttLatency) {
|
||||
this.logger.debug(`turning notifySttLatency ${this.notifySttLatency ? 'on' : 'off'}`);
|
||||
cs.notifySttLatencyEnabled = this.notifySttLatency;
|
||||
}
|
||||
|
||||
if (this.onHoldMusic) {
|
||||
cs.onHoldMusic = this.onHoldMusic;
|
||||
}
|
||||
@@ -318,7 +329,10 @@ class TaskConfig extends Task {
|
||||
voiceMs: this.vad.voiceMs || 250,
|
||||
silenceMs: this.vad.silenceMs || 150,
|
||||
strategy: this.vad.strategy || 'one-shot',
|
||||
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2
|
||||
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2,
|
||||
vendor: this.vad.vendor || 'silero',
|
||||
threshold: this.vad.threshold || 0.5,
|
||||
speechPadMs: this.vad.speechPadMs || 30,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,7 @@ const parseDecibels = require('../utils/parse-decibels');
|
||||
const debug = require('debug')('jambonz:feature-server');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
const {ANCHOR_MEDIA_ALWAYS,
|
||||
JAMBONZ_DISABLE_DIAL_PAI_HEADER,
|
||||
JAMBONZ_DIAL_PAI_HEADER,
|
||||
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER} = require('../config');
|
||||
const { isOnhold, isOpusFirst } = require('../utils/sdp-utils');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
@@ -109,6 +109,7 @@ class TaskDial extends Task {
|
||||
this.tag = this.data.tag;
|
||||
this.boostAudioSignal = this.data.boostAudioSignal;
|
||||
this._mediaPath = MediaPath.FullMedia;
|
||||
this.forwardPAI = this.data.forwardPAI;
|
||||
|
||||
if (this.dtmfHook) {
|
||||
const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
|
||||
@@ -397,7 +398,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,16 +420,21 @@ 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');
|
||||
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');
|
||||
res.send(err.statusCode || 501);
|
||||
@@ -539,13 +549,14 @@ class TaskDial extends Task {
|
||||
let sbcAddress = this.proxy || getSBC();
|
||||
const teamsInfo = {};
|
||||
let fqdn;
|
||||
|
||||
const forwardPAI = this.forwardPAI ?? JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
|
||||
this.logger.debug(forwardPAI, 'forwardPAI value');
|
||||
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
||||
this.headers = {
|
||||
'X-Account-Sid': cs.accountSid,
|
||||
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
|
||||
...(direction === 'outbound' && callInfo.sbcCallid && {'X-CID': callInfo.sbcCallid}),
|
||||
...(!JAMBONZ_DISABLE_DIAL_PAI_HEADER && req && {
|
||||
...(req && forwardPAI && {
|
||||
...(req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
||||
...(req.has('Privacy') && {'Privacy': req.get('Privacy')}),
|
||||
}),
|
||||
@@ -986,7 +997,7 @@ class TaskDial extends Task {
|
||||
this.epOther = null;
|
||||
this._mediaPath = releaseEntirely ? MediaPath.NoMedia : MediaPath.PartialMedia;
|
||||
this.logger.info(
|
||||
`Dial:_releaseMedia - successfully released media from freewitch, media path is now ${this._mediaPath}`);
|
||||
`Dial:_releaseMedia - successfully released media from freeswitch, media path is now ${this._mediaPath}`);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_releaseMedia error');
|
||||
}
|
||||
@@ -995,7 +1006,7 @@ class TaskDial extends Task {
|
||||
async reAnchorMedia(cs, sd) {
|
||||
if (cs.ep && sd.ep) return;
|
||||
|
||||
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freewitch');
|
||||
this.logger.info('Dial:reAnchorMedia - re-anchoring media to freeswitch');
|
||||
await Promise.all([sd.reAnchorMedia(this._mediaPath), cs.reAnchorMedia(this._mediaPath)]);
|
||||
this.epOther = cs.ep;
|
||||
|
||||
@@ -1003,7 +1014,7 @@ class TaskDial extends Task {
|
||||
|
||||
this._mediaPath = MediaPath.FullMedia;
|
||||
this.logger.info(
|
||||
`Dial:_releaseMedia - successfully re-anchored media to freewitch, media path is now ${this._mediaPath}`);
|
||||
`Dial:_releaseMedia - successfully re-anchored media to freeswitch, media path is now ${this._mediaPath}`);
|
||||
}
|
||||
|
||||
// Handle RE-INVITE hold from caller leg.
|
||||
@@ -1087,7 +1098,8 @@ class TaskDial extends Task {
|
||||
accountInfo: this.cs.accountInfo,
|
||||
tasks,
|
||||
rootSpan: this.cs.rootSpan,
|
||||
req: this.cs.req
|
||||
req: this.cs.req,
|
||||
tmpFiles: this.cs.tmpFiles,
|
||||
});
|
||||
await this._onHoldSession.exec();
|
||||
this._onHoldSession = null;
|
||||
|
||||
@@ -83,7 +83,8 @@ class TaskDub extends TtsTask {
|
||||
action: 'playOnTrack',
|
||||
track: this.track,
|
||||
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
|
||||
});
|
||||
}
|
||||
|
||||
@@ -370,7 +370,8 @@ class TaskEnqueue extends Task {
|
||||
accountInfo: cs.accountInfo,
|
||||
tasks: tasksToRun,
|
||||
rootSpan: cs.rootSpan,
|
||||
req: cs.req
|
||||
req: cs.req,
|
||||
tmpFiles: cs.tmpFiles,
|
||||
});
|
||||
await this._playSession.exec();
|
||||
this._playSession = null;
|
||||
|
||||
@@ -11,7 +11,9 @@ const {
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
DeepgramRiverTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
CartesiaTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VadDetection,
|
||||
VerbioTranscriptionEvents,
|
||||
@@ -138,7 +140,11 @@ class TaskGather extends SttTask {
|
||||
try {
|
||||
await this.handling(cs, obj);
|
||||
} 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.notifyTaskDone();
|
||||
return;
|
||||
@@ -262,11 +268,16 @@ class TaskGather extends SttTask {
|
||||
.catch((err) => {
|
||||
process();
|
||||
});
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
if (this.sayTask.isStreamingTts && !this.sayTask.closeOnStreamEmpty) {
|
||||
// if streaming tts, we do not wait for it to complete if it is not closing the stream automatically
|
||||
process();
|
||||
});
|
||||
} else {
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
process();
|
||||
});
|
||||
}
|
||||
}
|
||||
else if (this.playTask) {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
||||
@@ -341,6 +352,7 @@ class TaskGather extends SttTask {
|
||||
this.sayTask?.span.end();
|
||||
this._stopVad();
|
||||
this._resolve('killed');
|
||||
cs.stopSttLatencyVad();
|
||||
}
|
||||
|
||||
updateTaskInProgress(opts) {
|
||||
@@ -356,6 +368,9 @@ class TaskGather extends SttTask {
|
||||
|
||||
_onDtmf(cs, ep, evt) {
|
||||
this.logger.debug(evt, 'TaskGather:_onDtmf');
|
||||
if (!this._timeoutTimer && this.timeout > 0) {
|
||||
this._startTimer();
|
||||
}
|
||||
clearTimeout(this.interDigitTimer);
|
||||
let resolved = false;
|
||||
if (this.dtmfBargein) {
|
||||
@@ -380,12 +395,7 @@ class TaskGather extends SttTask {
|
||||
if (this.digitBuffer.length === 0 && this.needsStt) {
|
||||
// DTMF is higher priority than STT.
|
||||
this.removeCustomEventListeners();
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
})
|
||||
.catch((err) => this.logger.error({err},
|
||||
` Received DTMF, Error stopping transcription for vendor ${this.vendor}`));
|
||||
this._stopTranscribing(ep);
|
||||
}
|
||||
this.digitBuffer += evt.dtmf;
|
||||
const len = this.digitBuffer.length;
|
||||
@@ -458,6 +468,16 @@ class TaskGather extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'deepgramriver':
|
||||
this.bugname = `${this.bugname_prefix}deepgramriver_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, DeepgramRiverTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, DeepgramRiverTranscriptionEvents.Connect, this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'soniox':
|
||||
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -546,6 +566,17 @@ class TaskGather extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
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':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -661,10 +692,14 @@ class TaskGather extends SttTask {
|
||||
target_sid: this.cs.callSid
|
||||
});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
|
||||
// Some vendor use single connection, that we cannot use onConnect event to track transcription start
|
||||
this.cs.emit('transcribe-start');
|
||||
}
|
||||
|
||||
_startTimer() {
|
||||
if (0 === this.timeout) return;
|
||||
this.logger.debug(`Starting timoutTimer of ${this.timeout}ms`);
|
||||
this._clearTimer();
|
||||
this._timeoutTimer = setTimeout(() => {
|
||||
if (this.isContinuousAsr) this._startAsrTimer();
|
||||
@@ -828,6 +863,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');
|
||||
@@ -840,6 +879,10 @@ class TaskGather extends SttTask {
|
||||
if (finished === 'true') return;
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
|
||||
// emit an event to the call session to track the time transcription is received
|
||||
cs.emit('on-transcription');
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
if (this._bufferedTranscripts.length === 0) {
|
||||
@@ -923,6 +966,12 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
}
|
||||
|
||||
// receive a final transcript, calculate the stt latency for this transcript
|
||||
const sttLatency = this.cs.calculateSttLatency();
|
||||
if (!emptyTranscript && sttLatency) {
|
||||
this.stt_latency_ms += `${sttLatency.stt_latency_ms},`;
|
||||
}
|
||||
|
||||
if (this.isContinuousAsr) {
|
||||
/* append the transcript and start listening again for asrTimeout */
|
||||
const t = evt.alternatives[0].transcript;
|
||||
@@ -1074,11 +1123,7 @@ class TaskGather extends SttTask {
|
||||
|
||||
async _startFallback(cs, ep, evt) {
|
||||
if (this.canFallback) {
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||
this._stopTranscribing(ep);
|
||||
try {
|
||||
this.logger.debug('gather:_startFallback');
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
@@ -1207,17 +1252,26 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
}
|
||||
|
||||
async _stopTranscribing(ep) {
|
||||
// Fix for https://github.com/jambonz/jambonz-feature-server/issues/1281
|
||||
// We should immediately call stop transcription from gather
|
||||
// so that next gather can start transcription immediately
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname,
|
||||
gracefulShutdown: false
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this.resolved) return;
|
||||
this.logger.error({err}, 'Error stopping transcription');
|
||||
});
|
||||
this.cs.emit('transcribe-stop');
|
||||
}
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
this.logger.info({evt}, `TaskGather:resolve with reason ${reason}`);
|
||||
if (this.needsStt && this.ep && this.ep.connected) {
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => {
|
||||
if (this.resolved) return;
|
||||
this.logger.error({err}, 'Error stopping transcription');
|
||||
});
|
||||
this._stopTranscribing(this.ep);
|
||||
}
|
||||
if (this.resolved) {
|
||||
this.logger.debug('TaskGather:_resolve - already resolved');
|
||||
@@ -1236,11 +1290,28 @@ class TaskGather extends SttTask {
|
||||
this._clearAsrTimer();
|
||||
this._clearFinalAsrTimer();
|
||||
|
||||
let sttLatencyMetrics = {};
|
||||
if (this.needsStt) {
|
||||
const sttLatency = this.cs.calculateSttLatency();
|
||||
if (sttLatency) {
|
||||
this.stt_latency_ms = this.stt_latency_ms.endsWith(',') ?
|
||||
this.stt_latency_ms.slice(0, -1) : this.stt_latency_ms;
|
||||
sttLatencyMetrics = {
|
||||
'stt.latency_ms': this.stt_latency_ms,
|
||||
'stt.talkspurts': JSON.stringify(sttLatency.talkspurts),
|
||||
'stt.start_time': sttLatency.stt_start_time,
|
||||
'stt.stop_time': sttLatency.stt_stop_time,
|
||||
'stt.usage': sttLatency.stt_usage,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.span.setAttributes({
|
||||
channel: 1,
|
||||
'stt.label': this.label || 'None',
|
||||
'stt.resolve': reason,
|
||||
'stt.result': JSON.stringify(evt)
|
||||
'stt.result': JSON.stringify(evt),
|
||||
...sttLatencyMetrics
|
||||
});
|
||||
|
||||
if (this.callSession && this.callSession.callGone) {
|
||||
@@ -1268,6 +1339,9 @@ class TaskGather extends SttTask {
|
||||
|
||||
let returnedVerbs = false;
|
||||
try {
|
||||
const latencies = Object.fromEntries(
|
||||
Object.entries(sttLatencyMetrics).map(([key, value]) => [key.replace('stt.', 'stt_'), value])
|
||||
);
|
||||
if (reason.startsWith('dtmf')) {
|
||||
if (this.parentTask) this.parentTask.emit('dtmf', evt);
|
||||
else {
|
||||
@@ -1281,7 +1355,7 @@ class TaskGather extends SttTask {
|
||||
else {
|
||||
this.emit('transcription', evt);
|
||||
this.logger.debug('TaskGather:_resolve - invoking performAction');
|
||||
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected'});
|
||||
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected', ...latencies});
|
||||
this.logger.debug({returnedVerbs}, 'TaskGather:_resolve - back from performAction');
|
||||
}
|
||||
}
|
||||
@@ -1289,20 +1363,20 @@ class TaskGather extends SttTask {
|
||||
if (this.parentTask) this.parentTask.emit('timeout', evt);
|
||||
else {
|
||||
this.emit('timeout', evt);
|
||||
returnedVerbs = await this.performAction({reason: 'timeout'});
|
||||
returnedVerbs = await this.performAction({reason: 'timeout', ...latencies});
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('stt-error')) {
|
||||
if (this.parentTask) this.parentTask.emit('stt-error', evt);
|
||||
else {
|
||||
this.emit('stt-error', evt);
|
||||
returnedVerbs = await this.performAction({reason: 'error', details: evt.error});
|
||||
returnedVerbs = await this.performAction({reason: 'error', details: evt.error, ...latencies});
|
||||
}
|
||||
} else if (reason.startsWith('stt-low-confidence')) {
|
||||
if (this.parentTask) this.parentTask.emit('stt-low-confidence', evt);
|
||||
else {
|
||||
this.emit('stt-low-confidence', evt);
|
||||
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence'});
|
||||
returnedVerbs = await this.performAction({speech:evt, reason: 'stt-low-confidence', ...latencies});
|
||||
}
|
||||
}
|
||||
} catch (err) { /*already logged error*/ }
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants');
|
||||
const {TaskName, TaskPreconditions, ListenEvents, ListenStatus} = require('../utils/constants.json');
|
||||
const makeTask = require('./make_task');
|
||||
const moment = require('moment');
|
||||
const MAX_PLAY_AUDIO_QUEUE_SIZE = 10;
|
||||
@@ -72,7 +72,7 @@ class TaskListen extends Task {
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
|
||||
}
|
||||
if (this.transcribeTask) this.transcribeTask.kill();
|
||||
if (this.transcribeTask) this.transcribeTask.kill(cs);
|
||||
this._removeListeners(ep);
|
||||
}
|
||||
|
||||
@@ -103,9 +103,12 @@ class TaskListen extends Task {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async updateListen(status) {
|
||||
async updateListen(status, silence = false) {
|
||||
if (!this.killed && this.ep && this.ep.connected) {
|
||||
const args = this._bugname ? [this._bugname] : [];
|
||||
const args = [
|
||||
...(this._bugname ? [this._bugname] : []),
|
||||
...(status === ListenStatus.Pause ? ([silence]) : []),
|
||||
];
|
||||
this.logger.info(`TaskListen:updateListen status ${status}`);
|
||||
switch (status) {
|
||||
case ListenStatus.Pause:
|
||||
|
||||
@@ -32,12 +32,14 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
this.auth = this.parent.auth;
|
||||
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');
|
||||
this.apiKey = apiKey;
|
||||
this.agentId = agent_id;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
this.llmOptions = this.data.llmOptions || {};
|
||||
|
||||
this.results = {
|
||||
completionReason: 'normal conversation end'
|
||||
@@ -105,24 +107,29 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
}
|
||||
);
|
||||
// merge with any existing tools
|
||||
this.data.llmOptions.selectedTools = [
|
||||
this.llmOptions.selectedTools = [
|
||||
...convertedTools,
|
||||
...(this.data.llmOptions.selectedTools || [])
|
||||
...(this.llmOptions.selectedTools || [])
|
||||
];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...this.data.llmOptions,
|
||||
model: this.model,
|
||||
...this.llmOptions,
|
||||
...(!this.agentId && {
|
||||
model: this.model,
|
||||
}),
|
||||
medium: {
|
||||
...(this.data.llmOptions.medium || {}),
|
||||
...(this.llmOptions.medium || {}),
|
||||
serverWebSocket: {
|
||||
inputSampleRate: 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',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
@@ -211,7 +218,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
async _onServerEvent(_ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
//this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
|
||||
/* server errors of some sort */
|
||||
if (type === 'error') {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -6,7 +6,9 @@ class TaskPlay extends Task {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.url = this.data.url;
|
||||
this.url = this.data.url.includes('?')
|
||||
? this.data.url.split('?')[0] + '?' + this.data.url.split('?')[1].replaceAll('.', '%2E')
|
||||
: this.data.url;
|
||||
this.seekOffset = this.data.seekOffset || -1;
|
||||
this.timeoutSecs = this.data.timeoutSecs || -1;
|
||||
this.loop = this.data.loop || 1;
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
178
lib/tasks/say.js
178
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});
|
||||
@@ -266,62 +267,125 @@ class TaskSay extends TtsTask {
|
||||
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)}..`);
|
||||
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)}`);
|
||||
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);
|
||||
}
|
||||
}
|
||||
});
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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'));
|
||||
}
|
||||
const onPlaybackStop = (evt) => {
|
||||
try {
|
||||
this.logger.debug({evt},
|
||||
`Say got playback-stop ${evt.variable_tts_playback_id ? evt.variable_tts_playback_id : ''}`);
|
||||
|
||||
if (this._playResolve) {
|
||||
(tts_error || response_code >= 300) ? this._playReject(new Error(evt.variable_tts_error)) :
|
||||
this._playResolve();
|
||||
/**
|
||||
* If we got a playback id on both the start and stop events, and they don't match,
|
||||
* then we must have received a playback-stop event for an earlier play request.
|
||||
*/
|
||||
const playbackId = this.getPlaybackId(segment);
|
||||
// eslint-disable-next-line max-len
|
||||
const unmatchedResponse = (!!playbackId && !!evt.variable_tts_playback_id) && evt.variable_tts_playback_id !== playbackId;
|
||||
if (unmatchedResponse) {
|
||||
this.logger.info({currentPlaybackId: playbackId, stopPlaybackId: 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);
|
||||
|
||||
const onPlaybackStart = (evt) => {
|
||||
try {
|
||||
const playbackId = this.getPlaybackId(segment);
|
||||
// eslint-disable-next-line max-len
|
||||
const unmatchedResponse = (!!playbackId && !!evt.variable_tts_playback_id) && evt.variable_tts_playback_id !== playbackId;
|
||||
if (unmatchedResponse) {
|
||||
this.logger.info({currentPlaybackId: playbackId, stopPlaybackId: evt.variable_tts_playback_id},
|
||||
'Say:exec playback-start - unmatched playback_id');
|
||||
ep.once('playback-start', this._boundOnPlaybackStart);
|
||||
return;
|
||||
}
|
||||
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');
|
||||
}
|
||||
};
|
||||
this._boundOnPlaybackStart = onPlaybackStart.bind(this);
|
||||
|
||||
ep.once('playback-stop', this._boundOnPlaybackStop);
|
||||
ep.once('playback-start', this._boundOnPlaybackStart);
|
||||
|
||||
// wait for playback-stop event received to confirm if the playback is successful
|
||||
this._playPromise = new Promise((resolve, reject) => {
|
||||
this._playResolve = resolve;
|
||||
@@ -397,6 +461,8 @@ class TaskSay extends TtsTask {
|
||||
.replace('playht_', 'playht.')
|
||||
.replace('cartesia_', 'cartesia.')
|
||||
.replace('rimelabs_', 'rimelabs.')
|
||||
.replace('resemble_', 'resemble.')
|
||||
.replace('inworld_', 'inworld.')
|
||||
.replace('verbio_', 'verbio.')
|
||||
.replace('elevenlabs_', 'elevenlabs.');
|
||||
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
||||
@@ -461,6 +527,14 @@ const spanMapping = {
|
||||
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'rimelabs.connect_time_ms': 'connect_ms',
|
||||
'rimelabs.final_response_time_ms': 'final_response_ms',
|
||||
// Resemble
|
||||
'resemble.connect_time_ms': 'connect_ms',
|
||||
'resemble.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.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'verbio.connect_time_ms': 'connect_ms',
|
||||
|
||||
@@ -111,7 +111,12 @@ class TaskSipRefer extends Task {
|
||||
/* get IP address of the SBC to use as hostname if needed */
|
||||
const {host} = parseUri(dlg.remote.uri);
|
||||
|
||||
if (!referTo.startsWith('<') && !referTo.startsWith('sip') && !referTo.startsWith('"')) {
|
||||
if (
|
||||
!referTo.startsWith('<') &&
|
||||
!referTo.startsWith('sip:') &&
|
||||
!referTo.startsWith('"') &&
|
||||
!referTo.startsWith('tel:')
|
||||
) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referTo = `sip:${referTo}@${host}`;
|
||||
}
|
||||
@@ -124,7 +129,12 @@ class TaskSipRefer extends Task {
|
||||
if (!referredByDisplayName) {
|
||||
referredByDisplayName = cs.req?.callingName;
|
||||
}
|
||||
if (!referredBy.startsWith('<') && !referredBy.startsWith('sip') && !referredBy.startsWith('"')) {
|
||||
if (
|
||||
!referredBy.startsWith('<') &&
|
||||
!referredBy.startsWith('sip:') &&
|
||||
!referredBy.startsWith('"') &&
|
||||
!referredBy.startsWith('tel:')
|
||||
) {
|
||||
/* they may have only provided a phone number/user */
|
||||
referredBy = `${referredByDisplayName ? `"${referredByDisplayName}"` : ''}<sip:${referredBy}@${host}>`;
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ const crypto = require('crypto');
|
||||
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const {JAMBONES_AWS_TRANSCRIBE_USE_GRPC} = require('../config');
|
||||
const {TaskName} = require('../utils/constants.json');
|
||||
|
||||
/**
|
||||
* "Please insert turns here: {{turns:4}}"
|
||||
@@ -84,6 +85,9 @@ class SttTask extends Task {
|
||||
/*bug name prefix */
|
||||
this.bugname_prefix = '';
|
||||
|
||||
// stt latency calculator
|
||||
this.stt_latency_ms = '';
|
||||
|
||||
}
|
||||
|
||||
async exec(cs, {ep, ep2}) {
|
||||
@@ -91,6 +95,12 @@ class SttTask extends Task {
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
|
||||
// start vad from stt latency calculator
|
||||
if (this.name !== TaskName.Gather ||
|
||||
this.name === TaskName.Gather && this.needsStt) {
|
||||
cs.startSttLatencyVad();
|
||||
}
|
||||
|
||||
// use session preferences if we don't have specific verb-level settings.
|
||||
if (cs.recognizer) {
|
||||
for (const k in cs.recognizer) {
|
||||
@@ -400,7 +410,7 @@ class SttTask extends Task {
|
||||
dgOptions.utteranceEndMs = dgOptions.utteranceEndMs || asrTimeout;
|
||||
}
|
||||
|
||||
_onVendorConnect(_cs, _ep) {
|
||||
_onVendorConnect(cs, _ep) {
|
||||
this.logger.debug(`TaskGather:_on${this.vendor}Connect`);
|
||||
}
|
||||
|
||||
|
||||
@@ -6,6 +6,7 @@ const {
|
||||
AwsTranscriptionEvents,
|
||||
AzureTranscriptionEvents,
|
||||
DeepgramTranscriptionEvents,
|
||||
DeepgramRiverTranscriptionEvents,
|
||||
SonioxTranscriptionEvents,
|
||||
CobaltTranscriptionEvents,
|
||||
IbmTranscriptionEvents,
|
||||
@@ -14,6 +15,7 @@ const {
|
||||
TranscribeStatus,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
CartesiaTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
@@ -136,22 +138,30 @@ 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'));
|
||||
}
|
||||
|
||||
this.cs.emit('transcribe-stop');
|
||||
|
||||
return stopTranscription;
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
const stopTranscription = this._stopTranscription();
|
||||
cs.stopSttLatencyVad();
|
||||
// hangup after 1 sec if we don't get a final transcription
|
||||
if (stopTranscription) this._timer = setTimeout(() => this.notifyTaskDone(), 1500);
|
||||
else this.notifyTaskDone();
|
||||
@@ -232,6 +242,15 @@ class TaskTranscribe extends SttTask {
|
||||
//if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
||||
|
||||
break;
|
||||
case 'deepgramriver':
|
||||
this.bugname = `${this.bugname_prefix}deepgramriver_transcribe`;
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, DeepgramRiverTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'soniox':
|
||||
this.bugname = `${this.bugname_prefix}soniox_transcribe`;
|
||||
this.addCustomEventListener(ep, SonioxTranscriptionEvents.Transcription,
|
||||
@@ -312,6 +331,17 @@ class TaskTranscribe extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
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':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -406,9 +436,16 @@ class TaskTranscribe extends SttTask {
|
||||
bugname: this.bugname,
|
||||
hostport: this.hostport
|
||||
});
|
||||
|
||||
// Some vendor use single connection, that we cannot use onConnect event to track transcription start
|
||||
this.cs.emit('transcribe-start');
|
||||
}
|
||||
|
||||
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');
|
||||
@@ -420,6 +457,9 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
|
||||
// emit an event to the call session to track the time transcription is received
|
||||
cs.emit('on-transcription');
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
|
||||
@@ -581,14 +621,28 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
async _resolve(channel, evt) {
|
||||
let sttLatencyMetrics = {};
|
||||
if (evt.is_final) {
|
||||
const sttLatency = this.cs.calculateSttLatency();
|
||||
if (sttLatency) {
|
||||
sttLatencyMetrics = {
|
||||
'stt.latency_ms': `${sttLatency.stt_latency_ms}`,
|
||||
'stt.talkspurts': JSON.stringify(sttLatency.talkspurts),
|
||||
'stt.start_time': sttLatency.stt_start_time,
|
||||
'stt.stop_time': sttLatency.stt_stop_time,
|
||||
'stt.usage': sttLatency.stt_usage,
|
||||
};
|
||||
}
|
||||
// time to reset the stt latency
|
||||
this.cs.emit('transcribe-start');
|
||||
/* we've got a final transcript, so end the otel child span for this channel */
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.label': this.label || 'None',
|
||||
'stt.resolve': 'transcript',
|
||||
'stt.result': JSON.stringify(evt)
|
||||
'stt.result': JSON.stringify(evt),
|
||||
...sttLatencyMetrics
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
@@ -597,9 +651,13 @@ class TaskTranscribe extends SttTask {
|
||||
if (this.transcriptionHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const latencies = Object.fromEntries(
|
||||
Object.entries(sttLatencyMetrics).map(([key, value]) => [key.replace('stt.', 'stt_'), value])
|
||||
);
|
||||
const payload = {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
...latencies,
|
||||
...(evt.alternatives && {speech: evt}),
|
||||
...(evt.type && {speechEvent: evt})
|
||||
};
|
||||
@@ -691,7 +749,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 {
|
||||
|
||||
@@ -3,6 +3,16 @@ const { TaskPreconditions } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const dbUtils = require('../utils/db-utils');
|
||||
|
||||
const extractPlaybackId = (str) => {
|
||||
// Match say:{...} and capture the content inside braces
|
||||
const match = str.match(/say:\{([^}]*)\}/);
|
||||
if (!match) return null;
|
||||
|
||||
// Look for playback_id=value within the captured content
|
||||
const playbackMatch = match[1].match(/playback_id=([^,]*)/);
|
||||
return playbackMatch ? playbackMatch[1] : null;
|
||||
};
|
||||
|
||||
class TtsTask extends Task {
|
||||
|
||||
constructor(logger, data, parentTask) {
|
||||
@@ -22,6 +32,11 @@ class TtsTask extends Task {
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
this.instructions = this.data.instructions;
|
||||
this.playbackIds = [];
|
||||
}
|
||||
|
||||
getPlaybackId(offset) {
|
||||
return this.playbackIds[offset];
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
@@ -185,6 +200,9 @@ class TtsTask extends Task {
|
||||
} else if (vendor === 'rimelabs') {
|
||||
credentials = credentials || {};
|
||||
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') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
@@ -277,6 +295,7 @@ class TtsTask extends Task {
|
||||
renderForCaching: preCache
|
||||
});
|
||||
if (!filePath.startsWith('say:')) {
|
||||
this.playbackIds.push(null);
|
||||
this.logger.debug(`Say: file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (this.otelSpan) {
|
||||
@@ -290,12 +309,24 @@ class TtsTask extends Task {
|
||||
vendor,
|
||||
language,
|
||||
characters: text.length,
|
||||
elapsedTime: rtt
|
||||
elapsedTime: rtt,
|
||||
servedFromCache,
|
||||
'id': this.id
|
||||
});
|
||||
}
|
||||
if (servedFromCache) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
language,
|
||||
servedFromCache,
|
||||
'id': this.id
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug('Say: a streaming tts api will be used');
|
||||
this.playbackIds.push(extractPlaybackId(filePath));
|
||||
this.logger.debug({playbackIds: this.playbackIds}, 'Say: a streaming tts api will be used');
|
||||
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
|
||||
return modifiedPath;
|
||||
}
|
||||
|
||||
@@ -281,13 +281,17 @@ module.exports = (logger) => {
|
||||
|
||||
/* set stt options */
|
||||
logger.info(`starting amd for vendor ${vendor} and language ${language}`);
|
||||
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, language, {
|
||||
vendor,
|
||||
hints,
|
||||
enhancedModel: true,
|
||||
altLanguages: opts.recognizer?.altLanguages || [],
|
||||
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
|
||||
});
|
||||
/* if opts contains recognizer object use that config for stt, otherwise use defaults */
|
||||
const rOpts = opts.recognizer ?
|
||||
opts.recognizer :
|
||||
{
|
||||
vendor,
|
||||
hints,
|
||||
enhancedModel: true,
|
||||
altLanguages: opts.recognizer?.altLanguages || [],
|
||||
initialSpeechTimeoutMs: opts.resolveTimeoutMs,
|
||||
};
|
||||
const sttOpts = amd.setChannelVarsForStt({name: TaskName.Gather}, sttCredentials, language, rOpts);
|
||||
|
||||
await ep.set(sttOpts).catch((err) => logger.info(err, 'Error setting channel variables'));
|
||||
|
||||
@@ -418,7 +422,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'));
|
||||
|
||||
@@ -65,7 +65,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
this.logger.info(`stopping background task: ${type}`);
|
||||
task.removeAllListeners();
|
||||
task.span.end();
|
||||
task.kill();
|
||||
task.kill(this.cs);
|
||||
// Remove task from managed List
|
||||
this.tasks.delete(type);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Alert": "alert",
|
||||
"Answer": "answer",
|
||||
"Conference": "conference",
|
||||
"Config": "config",
|
||||
@@ -95,6 +96,11 @@
|
||||
"ConnectFailure": "deepgram_transcribe::connect_failed",
|
||||
"Connect": "deepgram_transcribe::connect"
|
||||
},
|
||||
"DeepgramRiverTranscriptionEvents": {
|
||||
"Transcription": "deepgramriver_transcribe::transcription",
|
||||
"ConnectFailure": "deepgramriver_transcribe::connect_failed",
|
||||
"Connect": "deepgramriver_transcribe::connect"
|
||||
},
|
||||
"SonioxTranscriptionEvents": {
|
||||
"Transcription": "soniox_transcribe::transcription",
|
||||
"Error": "soniox_transcribe::error"
|
||||
@@ -167,9 +173,18 @@
|
||||
"ConnectFailure": "voxist_transcribe::connect_failed",
|
||||
"Connect": "voxist_transcribe::connect"
|
||||
},
|
||||
"CartesiaTranscriptionEvents": {
|
||||
"Transcription": "cartesia_transcribe::transcription",
|
||||
"Error": "cartesia_transcribe::error",
|
||||
"ConnectFailure": "cartesia_transcribe::connect_failed",
|
||||
"Connect": "cartesia_transcribe::connect"
|
||||
},
|
||||
"VadDetection": {
|
||||
"Detection": "vad_detect:detection"
|
||||
},
|
||||
"SileroVadDetection": {
|
||||
"Detection": "vad_silero:detect"
|
||||
},
|
||||
"ListenEvents": {
|
||||
"Connect": "mod_audio_fork::connect",
|
||||
"ConnectFailure": "mod_audio_fork::connect_failed",
|
||||
@@ -237,6 +252,7 @@
|
||||
"KillReason": {
|
||||
"Hangup": "hangup",
|
||||
"Replaced": "replaced",
|
||||
"ReferComplete": "refer-complete",
|
||||
"MediaTimeout": "media_timeout"
|
||||
},
|
||||
"HookMsgTypes": [
|
||||
|
||||
@@ -81,6 +81,10 @@ const speechMapper = (cred) => {
|
||||
obj.deepgram_tts_uri = o.deepgram_tts_uri;
|
||||
obj.deepgram_stt_use_tls = o.deepgram_stt_use_tls;
|
||||
}
|
||||
else if ('deepgramriver' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('soniox' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
@@ -110,6 +114,7 @@ const speechMapper = (cred) => {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
obj.stt_model_id = o.stt_model_id;
|
||||
obj.embedding = o.embedding;
|
||||
obj.options = o.options;
|
||||
}
|
||||
@@ -119,9 +124,22 @@ const speechMapper = (cred) => {
|
||||
obj.model_id = o.model_id;
|
||||
obj.options = o.options;
|
||||
}
|
||||
else if ('resemble' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.resemble_tts_use_tls = o.resemble_tts_use_tls;
|
||||
obj.resemble_tts_uri = o.resemble_tts_uri;
|
||||
}
|
||||
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) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.service_version = o.service_version;
|
||||
}
|
||||
else if ('voxist' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
|
||||
@@ -41,7 +41,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
constructor(logger, account_sid, hook, secret) {
|
||||
super(logger, account_sid, hook, secret);
|
||||
|
||||
this.method = hook.method || 'POST';
|
||||
this.method = hook.method?.toUpperCase() || 'POST';
|
||||
this.authHeader = basicAuth(hook.username, hook.password);
|
||||
this.backoffMs = 500;
|
||||
|
||||
@@ -111,7 +111,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
const method = hook.method?.toUpperCase() || 'POST';
|
||||
let buf = '';
|
||||
httpHeaders = {
|
||||
...httpHeaders,
|
||||
@@ -119,7 +119,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
};
|
||||
|
||||
assert.ok(url, 'HttpRequestor:request url was not provided');
|
||||
assert.ok, (['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
assert.ok(['GET', 'POST'].includes(method), `HttpRequestor:request method must be 'GET' or 'POST' not ${method}`);
|
||||
const startAt = process.hrtime();
|
||||
|
||||
/* if we have an absolute url, and it is ws then do a websocket connection */
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -431,7 +401,8 @@ class SingleDialer extends Emitter {
|
||||
accountInfo: this.accountInfo,
|
||||
tasks,
|
||||
rootSpan: this.rootSpan,
|
||||
req: this.req
|
||||
req: this.req,
|
||||
tmpFiles: cs.tmpFiles,
|
||||
});
|
||||
await cs.exec();
|
||||
|
||||
@@ -528,8 +499,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'
|
||||
|
||||
0
lib/utils/process-monitor.js
Normal file
0
lib/utils/process-monitor.js
Normal file
197
lib/utils/stt-latency-calculator.js
Normal file
197
lib/utils/stt-latency-calculator.js
Normal file
@@ -0,0 +1,197 @@
|
||||
const { assert } = require('console');
|
||||
const Emitter = require('events');
|
||||
const {
|
||||
VadDetection,
|
||||
SileroVadDetection
|
||||
} = require('../utils/constants.json');
|
||||
|
||||
class SttLatencyCalculator extends Emitter {
|
||||
constructor({ logger, cs}) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.cs = cs;
|
||||
this.isRunning = false;
|
||||
this.isInTalkSpurt = false;
|
||||
this.start_talking_time = 0;
|
||||
this.talkspurts = [];
|
||||
this.vendor = this.cs.vad?.vendor || 'silero';
|
||||
this.stt_start_time = 0;
|
||||
this.stt_stop_time = 0;
|
||||
this.stt_on_transcription_time = 0;
|
||||
}
|
||||
|
||||
set sttStartTime(time) {
|
||||
this.stt_start_time = time;
|
||||
}
|
||||
|
||||
get sttStartTime() {
|
||||
return this.stt_start_time || 0;
|
||||
}
|
||||
|
||||
set sttStopTime(time) {
|
||||
this.stt_stop_time = time;
|
||||
}
|
||||
|
||||
get sttStopTime() {
|
||||
return this.stt_stop_time || 0;
|
||||
}
|
||||
|
||||
set sttOnTranscriptionTime(time) {
|
||||
this.stt_on_transcription_time = time;
|
||||
}
|
||||
|
||||
get sttOnTranscriptionTime() {
|
||||
return this.stt_on_transcription_time || 0;
|
||||
}
|
||||
|
||||
_onVadDetected(_ep, _evt, fsEvent) {
|
||||
if (fsEvent.getHeader('detected-event') === 'stop_talking') {
|
||||
if (this.isInTalkSpurt) {
|
||||
this.talkspurts.push({
|
||||
start: this.start_talking_time,
|
||||
stop: Date.now()
|
||||
});
|
||||
}
|
||||
|
||||
this.start_talking_time = 0;
|
||||
this.isInTalkSpurt = false;
|
||||
} else if (fsEvent.getHeader('detected-event') === 'start_talking') {
|
||||
this.start_talking_time = Date.now();
|
||||
this.isInTalkSpurt = true;
|
||||
}
|
||||
}
|
||||
|
||||
_startVad() {
|
||||
assert(!this.isRunning, 'Latency calculator is already running');
|
||||
assert(this.cs.ep, 'Callsession has no endpoint to start the latency calculator');
|
||||
const ep = this.cs.ep;
|
||||
if (!ep.sttLatencyVadHandler) {
|
||||
ep.sttLatencyVadHandler = this._onVadDetected.bind(this, ep);
|
||||
if (this.vendor === 'silero') {
|
||||
ep.addCustomEventListener(SileroVadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
} else {
|
||||
ep.addCustomEventListener(VadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
}
|
||||
}
|
||||
this.stop_talking_time = 0;
|
||||
this.start_talking_time = 0;
|
||||
this.vad = {
|
||||
...(this.cs.vad || {}),
|
||||
strategy: 'continuous',
|
||||
bugname: 'stt-latency-calculator-vad',
|
||||
vendor: this.vendor
|
||||
};
|
||||
|
||||
ep.startVadDetection(this.vad);
|
||||
this.isRunning = true;
|
||||
}
|
||||
|
||||
_stopVad() {
|
||||
if (this.isRunning) {
|
||||
this.logger.warn('Latency calculator is still running, stopping VAD detection');
|
||||
const ep = this.cs.ep;
|
||||
ep.stopVadDetection(this.vad);
|
||||
if (ep.sttLatencyVadHandler) {
|
||||
if (this.vendor === 'silero') {
|
||||
this.ep?.removeCustomEventListener(SileroVadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
} else {
|
||||
this.ep?.removeCustomEventListener(VadDetection.Detection, ep.sttLatencyVadHandler);
|
||||
}
|
||||
ep.sttLatencyVadHandler = null;
|
||||
}
|
||||
this.isRunning = false;
|
||||
this.logger.info('STT Latency Calculator stopped');
|
||||
}
|
||||
}
|
||||
|
||||
start() {
|
||||
if (this.isRunning) {
|
||||
this.logger.warn('Latency calculator is already running');
|
||||
return;
|
||||
}
|
||||
if (!this.cs.ep) {
|
||||
this.logger.error('Callsession has no endpoint to start the latency calculator');
|
||||
return;
|
||||
}
|
||||
this._startVad();
|
||||
this.logger.debug('STT Latency Calculator started');
|
||||
}
|
||||
|
||||
stop() {
|
||||
this._stopVad();
|
||||
}
|
||||
|
||||
toUnixTimestamp(date) {
|
||||
return Math.floor(date / 1000);
|
||||
}
|
||||
|
||||
calculateLatency() {
|
||||
if (!this.isRunning) {
|
||||
this.logger.debug('Latency calculator is not running, cannot calculate latency, returning default values');
|
||||
return null;
|
||||
}
|
||||
|
||||
const stt_stop_time = this.stt_stop_time || Date.now();
|
||||
if (this.isInTalkSpurt) {
|
||||
this.talkspurts.push({
|
||||
start: this.start_talking_time,
|
||||
stop: stt_stop_time
|
||||
});
|
||||
this.isInTalkSpurt = false;
|
||||
this.start_talking_time = 0;
|
||||
}
|
||||
const stt_on_transcription_time = this.stt_on_transcription_time || stt_stop_time;
|
||||
const start_talking_time = this.talkspurts[0]?.start;
|
||||
let lastIdx = this.talkspurts.length - 1;
|
||||
lastIdx = lastIdx < 0 ? 0 : lastIdx;
|
||||
const stop_talking_time = this.talkspurts[lastIdx]?.stop || stt_stop_time;
|
||||
|
||||
return {
|
||||
stt_start_time: this.toUnixTimestamp(this.stt_start_time),
|
||||
stt_stop_time: this.toUnixTimestamp(stt_stop_time),
|
||||
start_talking_time: this.toUnixTimestamp(start_talking_time),
|
||||
stop_talking_time: this.toUnixTimestamp(stop_talking_time),
|
||||
stt_latency: parseFloat((Math.abs(stt_on_transcription_time - stop_talking_time)) / 1000).toFixed(2),
|
||||
stt_latency_ms: Math.abs(stt_on_transcription_time - stop_talking_time),
|
||||
stt_usage: parseFloat((stt_stop_time - this.stt_start_time) / 1000).toFixed(2),
|
||||
talkspurts: this.talkspurts.map((ts) =>
|
||||
([this.toUnixTimestamp(ts.start || 0), this.toUnixTimestamp(ts.stop || 0)]))
|
||||
};
|
||||
}
|
||||
|
||||
resetTime() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.stt_start_time = Date.now();
|
||||
this.stt_stop_time = 0;
|
||||
this.stt_on_transcription_time = 0;
|
||||
this.clearTalkspurts();
|
||||
this.logger.info('STT Latency Calculator reset');
|
||||
}
|
||||
|
||||
onTranscriptionReceived() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.stt_on_transcription_time = Date.now();
|
||||
this.logger.debug(`CallSession:on-transcription set to ${this.stt_on_transcription_time}`);
|
||||
}
|
||||
|
||||
onTranscribeStop() {
|
||||
if (!this.isRunning) {
|
||||
return;
|
||||
}
|
||||
this.stt_stop_time = Date.now();
|
||||
this.logger.debug(`CallSession:transcribe-stop set to ${this.stt_stop_time}`);
|
||||
}
|
||||
|
||||
clearTalkspurts() {
|
||||
this.talkspurts = [];
|
||||
if (!this.isInTalkSpurt) {
|
||||
this.start_talking_time = 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = SttLatencyCalculator;
|
||||
@@ -110,6 +110,10 @@ const stickyVars = {
|
||||
voxist: [
|
||||
'VOXIST_API_KEY',
|
||||
],
|
||||
cartesia: [
|
||||
'CARTESIA_API_KEY',
|
||||
'CARTESIA_MODEL_ID'
|
||||
],
|
||||
speechmatics: [
|
||||
'SPEECHMATICS_API_KEY',
|
||||
'SPEECHMATICS_HOST',
|
||||
@@ -335,6 +339,25 @@ const normalizeDeepgram = (evt, channel, language, shortUtterance) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeDeepgramRiver = (evt, channel, language, shortUtterance) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.event === 'EndOfTurn',
|
||||
alternatives: [
|
||||
{
|
||||
confidence: evt.end_of_turn_confidence,
|
||||
transcript: evt.transcript,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'deepgramriver',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeNvidia = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const alternatives = (evt.alternatives || [])
|
||||
@@ -519,16 +542,27 @@ const normalizeAws = (evt, channel, language) => {
|
||||
|
||||
const normalizeAssemblyAi = (evt, channel, language) => {
|
||||
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 {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.message_type === 'FinalTranscript',
|
||||
alternatives: [
|
||||
{
|
||||
confidence: evt.confidence,
|
||||
transcript: evt.text,
|
||||
}
|
||||
],
|
||||
is_final,
|
||||
alternatives,
|
||||
vendor: {
|
||||
name: 'assemblyai',
|
||||
evt: copy
|
||||
@@ -555,6 +589,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 copy = JSON.parse(JSON.stringify(evt));
|
||||
const is_final = evt.message === 'AddTranscript';
|
||||
@@ -616,6 +669,8 @@ module.exports = (logger) => {
|
||||
switch (vendor) {
|
||||
case 'deepgram':
|
||||
return normalizeDeepgram(evt, channel, language, shortUtterance);
|
||||
case 'deepgramriver':
|
||||
return normalizeDeepgramRiver(evt, channel, language, shortUtterance);
|
||||
case 'microsoft':
|
||||
return normalizeMicrosoft(evt, channel, language, punctuation);
|
||||
case 'google':
|
||||
@@ -636,6 +691,8 @@ module.exports = (logger) => {
|
||||
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
|
||||
case 'voxist':
|
||||
return normalizeVoxist(evt, channel, language);
|
||||
case 'cartesia':
|
||||
return normalizeCartesia(evt, channel, language);
|
||||
case 'verbio':
|
||||
return normalizeVerbio(evt, channel, language);
|
||||
case 'speechmatics':
|
||||
@@ -728,12 +785,15 @@ module.exports = (logger) => {
|
||||
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
|
||||
AWS_REGION: sttCredentials.region,
|
||||
AWS_SECURITY_TOKEN: sttCredentials.securityToken
|
||||
AWS_SECURITY_TOKEN: sttCredentials.securityToken,
|
||||
AWS_SESSION_TOKEN: sttCredentials.sessionToken ? sttCredentials.sessionToken : sttCredentials.securityToken
|
||||
}),
|
||||
...(awsOptions.accessKey && {AWS_ACCESS_KEY_ID: awsOptions.accessKey}),
|
||||
...(awsOptions.secretKey && {AWS_SECRET_ACCESS_KEY: awsOptions.secretKey}),
|
||||
...(awsOptions.region && {AWS_REGION: awsOptions.region}),
|
||||
...(awsOptions.securityToken && {AWS_SECURITY_TOKEN: awsOptions.securityToken}),
|
||||
...(awsOptions.sessionToken && {AWS_SESSION_TOKEN: awsOptions.sessionToken ?
|
||||
awsOptions.sessionToken : awsOptions.securityToken}),
|
||||
...(awsOptions.languageModelName && {AWS_LANGUAGE_MODEL_NAME: awsOptions.languageModelName}),
|
||||
...(awsOptions.piiEntityTypes?.length && {AWS_PII_ENTITY_TYPES: awsOptions.piiEntityTypes.join(',')}),
|
||||
...(awsOptions.piiIdentifyEntities && {AWS_PII_IDENTIFY_ENTITIES: true}),
|
||||
@@ -834,6 +894,14 @@ module.exports = (logger) => {
|
||||
const deepgramUri = deepgramOptions.deepgramSttUri || sttCredentials.deepgram_stt_uri;
|
||||
const useTls = deepgramOptions.deepgramSttUseTls || sttCredentials.deepgram_stt_use_tls;
|
||||
|
||||
// DH (2025-08-11) entity_prompt is currently limited to 100 words
|
||||
const entityPrompt = deepgramOptions.entityPrompt ?
|
||||
deepgramOptions.entityPrompt
|
||||
.split(/\s+/)
|
||||
.slice(0, 100)
|
||||
.join(' ')
|
||||
: undefined;
|
||||
|
||||
/* default to a sensible model if not supplied */
|
||||
if (!model) {
|
||||
model = selectDefaultDeepgramModel(task, language);
|
||||
@@ -892,7 +960,24 @@ module.exports = (logger) => {
|
||||
...(deepgramOptions.fillerWords) &&
|
||||
{DEEPGRAM_SPEECH_FILLER_WORDS: deepgramOptions.fillerWords},
|
||||
...((Array.isArray(deepgramOptions.keyterms) && deepgramOptions.keyterms.length > 0) &&
|
||||
{DEEPGRAM_SPEECH_KEYTERMS: deepgramOptions.keyterms.join(',')})
|
||||
{DEEPGRAM_SPEECH_KEYTERMS: deepgramOptions.keyterms.join(',')}),
|
||||
...(deepgramOptions.mipOptOut && {DEEPGRAM_SPEECH_MIP_OPT_OUT: deepgramOptions.mipOptOut}),
|
||||
...(entityPrompt && {DEEPGRAM_SPEECH_ENTITY_PROMPT: entityPrompt}),
|
||||
};
|
||||
}
|
||||
else if ('deepgramriver' === vendor) {
|
||||
const {
|
||||
preflightThreshold,
|
||||
eotThreshold,
|
||||
eotTimeoutMs,
|
||||
mipOptOut
|
||||
} = rOpts.deepgramOptions || {};
|
||||
opts = {
|
||||
DEEPGRAM_API_KEY: sttCredentials.api_key,
|
||||
...(preflightThreshold && {DEEPGRAM_SPEECH_PRELIGHT_THRESHOLD: preflightThreshold}),
|
||||
...(eotThreshold && {DEEPGRAM_SPEECH_EOT_THRESHOLD: eotThreshold}),
|
||||
...(eotTimeoutMs && {DEEPGRAM_SPEECH_EOT_TIMEOUT_MS: eotTimeoutMs}),
|
||||
...(mipOptOut && {DEEPGRAM_SPEECH_MIP_OPT_OUT: mipOptOut}),
|
||||
};
|
||||
}
|
||||
else if ('soniox' === vendor) {
|
||||
@@ -993,8 +1078,28 @@ module.exports = (logger) => {
|
||||
};
|
||||
}
|
||||
else if ('assemblyai' === vendor) {
|
||||
const serviceVersion = rOpts.assemblyAiOptions?.serviceVersion || sttCredentials.service_version || 'v2';
|
||||
const {
|
||||
formatTurns,
|
||||
endOfTurnConfidenceThreshold,
|
||||
minEndOfTurnSilenceWhenConfident,
|
||||
maxTurnSilence
|
||||
} = rOpts.assemblyAiOptions || {};
|
||||
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) &&
|
||||
{ASSEMBLYAI_API_KEY: sttCredentials.api_key},
|
||||
...(rOpts.hints?.length > 0 &&
|
||||
@@ -1008,6 +1113,16 @@ module.exports = (logger) => {
|
||||
{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) {
|
||||
const {openaiOptions = {}} = rOpts;
|
||||
const model = openaiOptions.model || rOpts.model || sttCredentials.model_id || 'whisper-1';
|
||||
|
||||
@@ -293,7 +293,7 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
/* send the message */
|
||||
this.ws.send(JSON.stringify(obj), async() => {
|
||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
if (obj.type !== 'llm:event') this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
// If session:reconnect is waiting for ack, hold here until ack to send queuedMsgs
|
||||
if (this._reconnectPromise) {
|
||||
try {
|
||||
|
||||
5916
package-lock.json
generated
5916
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
14
package.json
14
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "0.9.4",
|
||||
"version": "0.9.5",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 18.x"
|
||||
@@ -27,14 +27,14 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-auto-scaling": "^3.549.0",
|
||||
"@aws-sdk/client-sns": "^3.549.0",
|
||||
"@jambonz/db-helpers": "^0.9.12",
|
||||
"@jambonz/db-helpers": "^0.9.16",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.13",
|
||||
"@jambonz/speech-utils": "^0.2.12",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.15",
|
||||
"@jambonz/speech-utils": "^0.2.22",
|
||||
"@jambonz/stats-collector": "^0.1.10",
|
||||
"@jambonz/time-series": "^0.2.13",
|
||||
"@jambonz/verb-specifications": "^0.0.105",
|
||||
"@jambonz/time-series": "^0.2.14",
|
||||
"@jambonz/verb-specifications": "^0.0.113",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
@@ -48,7 +48,7 @@
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^4.0.3",
|
||||
"drachtio-fsmrf": "^4.1.2",
|
||||
"drachtio-srf": "^5.0.5",
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.0.1",
|
||||
|
||||
@@ -16,6 +16,7 @@ require('./sip-request-tests');
|
||||
require('./create-call-test');
|
||||
require('./play-tests');
|
||||
require('./sip-refer-tests');
|
||||
require('./sip-refer-handler-tests');
|
||||
require('./listen-tests');
|
||||
require('./config-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);
|
||||
}
|
||||
});
|
||||
@@ -58,6 +58,46 @@ test('\'refer\' tests w/202 and NOTIFY', {timeout: 25000}, async(t) => {
|
||||
}
|
||||
});
|
||||
|
||||
test('\'refer\' tests tel:', {timeout: 25000}, async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
try {
|
||||
await connect(srf);
|
||||
|
||||
// GIVEN
|
||||
const verbs = [
|
||||
{
|
||||
verb: 'say',
|
||||
text: 'silence_stream://100'
|
||||
},
|
||||
{
|
||||
verb: 'sip:refer',
|
||||
referTo: 'tel:+1234567890',
|
||||
actionHook: '/actionHook'
|
||||
}
|
||||
];
|
||||
const noVerbs = [];
|
||||
|
||||
const from = 'refer_with_tel';
|
||||
await provisionCallHook(from, verbs);
|
||||
await provisionActionHook(from, noVerbs)
|
||||
|
||||
// THEN
|
||||
await sippUac('uac-refer-with-notify.xml', '172.38.0.10', from);
|
||||
t.pass('refer: successfully received 202 Accepted');
|
||||
await sleepFor(1000);
|
||||
const obj = await getJSON(`http:127.0.0.1:3100/lastRequest/${from}_actionHook`);
|
||||
t.ok(obj.body.final_referred_call_status === 200, 'refer: successfully received NOTIFY with 200 OK');
|
||||
// console.log(`obj: ${JSON.stringify(obj)}`);
|
||||
disconnect();
|
||||
} catch (err) {
|
||||
console.log(`error received: ${err}`);
|
||||
disconnect();
|
||||
t.error(err);
|
||||
}
|
||||
});
|
||||
|
||||
test('\'refer\' tests w/202 but no NOTIFY', {timeout: 25000}, async(t) => {
|
||||
clearModule.all();
|
||||
const {srf, disconnect} = require('../app');
|
||||
|
||||
@@ -99,6 +99,24 @@ app.post('/actionHook', (req, res) => {
|
||||
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
|
||||
* For the hook to return
|
||||
|
||||
Reference in New Issue
Block a user