mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-12 17:28:49 +00:00
Compare commits
25 Commits
v0.9.5-6
...
v0.9.6-rc1
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ceb9a7a3bd | ||
|
|
ff5f9acaf8 | ||
|
|
96cdc2936b | ||
|
|
6120dcbe96 | ||
|
|
96d72216e2 | ||
|
|
faee30278b | ||
|
|
325af42946 | ||
|
|
9848152d5b | ||
|
|
2468557aef | ||
|
|
3c3dfa81d3 | ||
|
|
961c2589ac | ||
|
|
e4ec0025c3 | ||
|
|
ba275ef547 | ||
|
|
83a8cf6d25 | ||
|
|
09220872ae | ||
|
|
fdce05fa40 | ||
|
|
3bd1dd6323 | ||
|
|
54dc172ebd | ||
|
|
e007e0e2d3 | ||
|
|
c5cd488fdf | ||
|
|
57982335e0 | ||
|
|
5cea91e18a | ||
|
|
e396b6aa98 | ||
|
|
9104ebb603 | ||
|
|
1ad0261336 |
@@ -139,6 +139,11 @@ const JAMBONES_USE_FREESWITCH_TIMER_FD = process.env.JAMBONES_USE_FREESWITCH_TIM
|
|||||||
const JAMBONES_DIAL_SBC_FOR_REGISTERED_USER = process.env.JAMBONES_DIAL_SBC_FOR_REGISTERED_USER || false;
|
const JAMBONES_DIAL_SBC_FOR_REGISTERED_USER = process.env.JAMBONES_DIAL_SBC_FOR_REGISTERED_USER || false;
|
||||||
const JAMBONES_MEDIA_TIMEOUT_MS = process.env.JAMBONES_MEDIA_TIMEOUT_MS || 0;
|
const JAMBONES_MEDIA_TIMEOUT_MS = process.env.JAMBONES_MEDIA_TIMEOUT_MS || 0;
|
||||||
const JAMBONES_MEDIA_HOLD_TIMEOUT_MS = process.env.JAMBONES_MEDIA_HOLD_TIMEOUT_MS || 0;
|
const JAMBONES_MEDIA_HOLD_TIMEOUT_MS = process.env.JAMBONES_MEDIA_HOLD_TIMEOUT_MS || 0;
|
||||||
|
const JAMBONES_WEBHOOK_ERROR_RETURN = parseInt(process.env.JAMBONES_WEBHOOK_ERROR_RETURN, 10) || 480;
|
||||||
|
|
||||||
|
/* say / tts */
|
||||||
|
const JAMBONES_SAY_CHUNK_SIZE = parseInt(process.env.JAMBONES_SAY_CHUNK_SIZE, 10) || 900;
|
||||||
|
|
||||||
// jambonz
|
// jambonz
|
||||||
const JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS =
|
const JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS =
|
||||||
process.env.JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS;
|
process.env.JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS;
|
||||||
@@ -231,5 +236,7 @@ module.exports = {
|
|||||||
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER,
|
JAMBONES_DIAL_SBC_FOR_REGISTERED_USER,
|
||||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||||
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
JAMBONES_MEDIA_HOLD_TIMEOUT_MS,
|
||||||
|
JAMBONES_SAY_CHUNK_SIZE,
|
||||||
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
|
JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS,
|
||||||
|
JAMBONES_WEBHOOK_ERROR_RETURN
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -291,7 +291,7 @@ router.post('/',
|
|||||||
}, {
|
}, {
|
||||||
...(account.enable_debug_log && {level: 'debug'})
|
...(account.enable_debug_log && {level: 'debug'})
|
||||||
});
|
});
|
||||||
app.requestor.logger = app.notifier.logger = sipLogger;
|
app.requestor.logger = app.notifier.logger = restDial.logger = sipLogger;
|
||||||
const callInfo = new CallInfo({
|
const callInfo = new CallInfo({
|
||||||
direction: CallDirection.Outbound,
|
direction: CallDirection.Outbound,
|
||||||
req: inviteReq,
|
req: inviteReq,
|
||||||
|
|||||||
@@ -12,7 +12,8 @@ const RootSpan = require('./utils/call-tracer');
|
|||||||
const listTaskNames = require('./utils/summarize-tasks');
|
const listTaskNames = require('./utils/summarize-tasks');
|
||||||
const {
|
const {
|
||||||
JAMBONES_MYSQL_REFRESH_TTL,
|
JAMBONES_MYSQL_REFRESH_TTL,
|
||||||
JAMBONES_DISABLE_DIRECT_P2P_CALL
|
JAMBONES_DISABLE_DIRECT_P2P_CALL,
|
||||||
|
JAMBONES_WEBHOOK_ERROR_RETURN
|
||||||
} = require('./config');
|
} = require('./config');
|
||||||
const { createJambonzApp } = require('./dynamic-apps');
|
const { createJambonzApp } = require('./dynamic-apps');
|
||||||
const { decrypt } = require('./utils/encrypt-decrypt');
|
const { decrypt } = require('./utils/encrypt-decrypt');
|
||||||
@@ -480,7 +481,7 @@ module.exports = function(srf, logger) {
|
|||||||
message: `${err?.message}`.trim()
|
message: `${err?.message}`.trim()
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
|
}).catch((err) => this.logger.info({err}, 'Error generating alert for parsing application'));
|
||||||
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
|
logger.info({err}, `Error retrieving or parsing application: ${err?.message}`);
|
||||||
res.send(480, {headers: {'X-Reason': err?.message || 'unknown'}});
|
res.send(JAMBONES_WEBHOOK_ERROR_RETURN, {headers: {'X-Reason': err?.message || 'unknown'}});
|
||||||
app.requestor.close(WS_CLOSE_CODES.GoingAway);
|
app.requestor.close(WS_CLOSE_CODES.GoingAway);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ class CallInfo {
|
|||||||
let srf;
|
let srf;
|
||||||
this.direction = opts.direction;
|
this.direction = opts.direction;
|
||||||
this.traceId = opts.traceId;
|
this.traceId = opts.traceId;
|
||||||
|
this.hasRecording = false;
|
||||||
this.callTerminationBy = undefined;
|
this.callTerminationBy = undefined;
|
||||||
if (opts.req) {
|
if (opts.req) {
|
||||||
const u = opts.req.getParsedHeader('from');
|
const u = opts.req.getParsedHeader('from');
|
||||||
|
|||||||
@@ -504,7 +504,12 @@ class CallSession extends Emitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
get isTtsStreamEnabled() {
|
get isTtsStreamEnabled() {
|
||||||
return this.backgroundTaskManager.isTaskRunning('ttsStream');
|
// 1st background tts stream
|
||||||
|
return this.backgroundTaskManager.isTaskRunning('ttsStream') ||
|
||||||
|
// 2nd current task streaming tts
|
||||||
|
TaskName.Say === this.currentTask?.name && this.currentTask?.isStreamingTts ||
|
||||||
|
// 3rd nested verb is streaming tts
|
||||||
|
TaskName.Gather === this.currentTask?.name && this.currentTask.sayTask?.isStreamingTts;
|
||||||
}
|
}
|
||||||
|
|
||||||
get isListenEnabled() {
|
get isListenEnabled() {
|
||||||
@@ -751,69 +756,101 @@ class CallSession extends Emitter {
|
|||||||
return this._fillerNoise;
|
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) {
|
async notifyRecordOptions(opts) {
|
||||||
const {action, silence} = opts;
|
const {action, silence = false, type = 'siprec'} = opts;
|
||||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||||
|
|
||||||
this.pauseOrResumeBackgroundListenIfRequired(action, silence);
|
if (type == 'cloud') {
|
||||||
|
switch (action) {
|
||||||
/* if we have not answered yet, just save the details for later */
|
case 'pauseCallRecording':
|
||||||
if (!this.dlg) {
|
if (this.backgroundTaskManager.isTaskRunning('record')) {
|
||||||
if (action === 'startCallRecording') {
|
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
|
||||||
this.recordOptions = opts;
|
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
|
||||||
return true;
|
backgroundListenTask.updateListen(
|
||||||
|
ListenStatus.Pause,
|
||||||
|
silence
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else { return false; }
|
||||||
|
case 'resumeCallRecording':
|
||||||
|
if (this.backgroundTaskManager.isTaskRunning('record')) {
|
||||||
|
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
|
||||||
|
const backgroundListenTask = this.backgroundTaskManager.getTask('record');
|
||||||
|
backgroundListenTask.updateListen(
|
||||||
|
ListenStatus.Resume,
|
||||||
|
silence
|
||||||
|
);
|
||||||
|
return true;
|
||||||
|
} else { return false; }
|
||||||
|
case 'startCallRecording':
|
||||||
|
if (!this.backgroundTaskManager.isTaskRunning('record')) {
|
||||||
|
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
|
||||||
|
this.callInfo.hasRecording = true;
|
||||||
|
this.updateCallStatus(Object.assign({}, this.callInfo.toJSON()), this.serviceUrl)
|
||||||
|
.catch((err) => this.logger.error(err, 'redis error'));
|
||||||
|
if (!this.dlg) {
|
||||||
|
// Call not yet answered so set flag to record on status change
|
||||||
|
this.application.record_all_calls = true;
|
||||||
|
} else {
|
||||||
|
this.backgroundTaskManager.newTask('record');
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
} else { return false; }
|
||||||
|
case 'stopCallRecording':
|
||||||
|
if (this.backgroundTaskManager.isTaskRunning('record')) {
|
||||||
|
this.logger.debug({action, silence, type}, 'CallSession:cloudRecording');
|
||||||
|
this.backgroundTaskManager.stop('record');
|
||||||
|
return true;
|
||||||
|
} else { return false; }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// SIPREC
|
||||||
|
/* if we have not answered yet, just save the details for later */
|
||||||
|
if (!this.dlg) {
|
||||||
|
if (action === 'startCallRecording') {
|
||||||
|
this.recordOptions = opts;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
return false;
|
||||||
}
|
}
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
/* check validity of request */
|
/* check validity of request */
|
||||||
if (action == 'startCallRecording' && this.recordState !== RecordState.RecordingOff) {
|
if (action == 'startCallRecording' && this.recordState !== RecordState.RecordingOff) {
|
||||||
this.logger.info({recordState: this.recordState},
|
this.logger.info({recordState: this.recordState},
|
||||||
'CallSession:notifyRecordOptions: recording is already started, ignoring request');
|
'CallSession:notifyRecordOptions: recording is already started, ignoring request');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (action == 'stopCallRecording' && this.recordState === RecordState.RecordingOff) {
|
if (action == 'stopCallRecording' && this.recordState === RecordState.RecordingOff) {
|
||||||
this.logger.info({recordState: this.recordState},
|
this.logger.info({recordState: this.recordState},
|
||||||
'CallSession:notifyRecordOptions: recording is already stopped, ignoring request');
|
'CallSession:notifyRecordOptions: recording is already stopped, ignoring request');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (action == 'pauseCallRecording' && this.recordState !== RecordState.RecordingOn) {
|
if (action == 'pauseCallRecording' && this.recordState !== RecordState.RecordingOn) {
|
||||||
this.logger.info({recordState: this.recordState},
|
this.logger.info({recordState: this.recordState},
|
||||||
'CallSession:notifyRecordOptions: cannot pause recording, ignoring request ');
|
'CallSession:notifyRecordOptions: cannot pause recording, ignoring request ');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (action == 'resumeCallRecording' && this.recordState !== RecordState.RecordingPaused) {
|
if (action == 'resumeCallRecording' && this.recordState !== RecordState.RecordingPaused) {
|
||||||
this.logger.info({recordState: this.recordState},
|
this.logger.info({recordState: this.recordState},
|
||||||
'CallSession:notifyRecordOptions: cannot resume recording, ignoring request ');
|
'CallSession:notifyRecordOptions: cannot resume recording, ignoring request ');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.recordOptions = opts;
|
this.recordOptions = opts;
|
||||||
|
|
||||||
switch (action) {
|
switch (action) {
|
||||||
case 'startCallRecording':
|
case 'startCallRecording':
|
||||||
return await this.startRecording();
|
return await this.startRecording();
|
||||||
case 'stopCallRecording':
|
case 'stopCallRecording':
|
||||||
return await this.stopRecording();
|
return await this.stopRecording();
|
||||||
case 'pauseCallRecording':
|
case 'pauseCallRecording':
|
||||||
return await this.pauseRecording();
|
return await this.pauseRecording();
|
||||||
case 'resumeCallRecording':
|
case 'resumeCallRecording':
|
||||||
return await this.resumeRecording();
|
return await this.resumeRecording();
|
||||||
default:
|
default:
|
||||||
throw new Error(`invalid record action ${action}`);
|
throw new Error(`invalid record action ${action}`);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -927,7 +964,7 @@ class CallSession extends Emitter {
|
|||||||
this.logger.debug('CallSession:enableBackgroundTtsStream - ttsStream enabled');
|
this.logger.debug('CallSession:enableBackgroundTtsStream - ttsStream enabled');
|
||||||
} else {
|
} else {
|
||||||
this.logger.debug(
|
this.logger.debug(
|
||||||
'CallSession:enableBackgroundTtsStream - ignoring request as call does not have required conditions');
|
'CallSession:enableBackgroundTtsStream - ignoring request; conditions not met (probably not using ws api)');
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info({err, say}, 'CallSession:enableBackgroundTtsStream - Error creating background tts stream task');
|
this.logger.info({err, say}, 'CallSession:enableBackgroundTtsStream - Error creating background tts stream task');
|
||||||
@@ -941,9 +978,11 @@ class CallSession extends Emitter {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
clearTtsStream() {
|
clearTtsStream() {
|
||||||
this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'user_interruption'})
|
if (this.isTtsStreamEnabled) {
|
||||||
.catch((err) => this.logger.info({err}, 'CallSession:clearTtsStream - Error sending user_interruption'));
|
this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'user_interruption'})
|
||||||
this.ttsStreamingBuffer?.clear();
|
.catch((err) => this.logger.info({err}, 'CallSession:clearTtsStream - Error sending user_interruption'));
|
||||||
|
this.ttsStreamingBuffer?.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
startTtsStream() {
|
startTtsStream() {
|
||||||
@@ -951,7 +990,7 @@ class CallSession extends Emitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
stopTtsStream() {
|
stopTtsStream() {
|
||||||
if (this.appIsUsingWebsockets) {
|
if (this.isTtsStreamEnabled) {
|
||||||
this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
|
this.requestor?.request('tts:streaming-event', '/streaming-event', {event_type: 'stream_closed'})
|
||||||
.catch((err) => this.logger.info({err}, 'CallSession:clearTtsStream - Error sending user_interruption'));
|
.catch((err) => this.logger.info({err}, 'CallSession:clearTtsStream - Error sending user_interruption'));
|
||||||
this.ttsStreamingBuffer?.stop();
|
this.ttsStreamingBuffer?.stop();
|
||||||
@@ -1043,7 +1082,7 @@ class CallSession extends Emitter {
|
|||||||
const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n'));
|
const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n'));
|
||||||
return {
|
return {
|
||||||
speech_credential_sid: credential.speech_credential_sid,
|
speech_credential_sid: credential.speech_credential_sid,
|
||||||
credentials: cred
|
credentials: cred,
|
||||||
};
|
};
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
const sid = this.accountInfo.account.account_sid;
|
const sid = this.accountInfo.account.account_sid;
|
||||||
@@ -1248,9 +1287,10 @@ class CallSession extends Emitter {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
writeAlerts({
|
writeAlerts({
|
||||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
alert_type: type === 'tts' ? AlertType.TTS_NOT_PROVISIONED : AlertType.STT_NOT_PROVISIONED,
|
||||||
account_sid: this.accountSid,
|
account_sid: this.accountSid,
|
||||||
vendor,
|
vendor,
|
||||||
|
label,
|
||||||
target_sid: this.callSid
|
target_sid: this.callSid
|
||||||
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
|
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
|
||||||
}
|
}
|
||||||
@@ -1988,7 +2028,7 @@ Duration=${duration} `
|
|||||||
return this._lccDub(opts.dub, callSid);
|
return this._lccDub(opts.dub, callSid);
|
||||||
}
|
}
|
||||||
else if (opts.boostAudioSignal) {
|
else if (opts.boostAudioSignal) {
|
||||||
return this._lccBoostAudioSignal(opts, callSid);
|
return this._lccBoostAudioSignal(opts.boostAudioSignal, callSid);
|
||||||
}
|
}
|
||||||
else if (opts.media_path) {
|
else if (opts.media_path) {
|
||||||
return this._lccMediaPath(opts.media_path, callSid);
|
return this._lccMediaPath(opts.media_path, callSid);
|
||||||
@@ -2460,6 +2500,36 @@ Duration=${duration} `
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
|
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
|
||||||
|
// Check for SipError type (e.g., 488 codec incompatibility)
|
||||||
|
const isSipError = err.name === 'SipError';
|
||||||
|
if (isSipError && err.status) {
|
||||||
|
// Extract Reason header from SIP response if available (e.g., Q.850;cause=88;text="INCOMPATIBLE_DESTINATION")
|
||||||
|
const sipReasonHeader = err.res?.msg?.headers?.reason;
|
||||||
|
this._endpointAllocationError = {
|
||||||
|
status: err.status,
|
||||||
|
reason: err.reason || 'Endpoint Allocation Failed',
|
||||||
|
sipReasonHeader
|
||||||
|
};
|
||||||
|
this.logger.info({endpointAllocationError: this._endpointAllocationError},
|
||||||
|
'Captured SipError for propagation to SBC');
|
||||||
|
|
||||||
|
// Send SIP error response immediately for inbound calls
|
||||||
|
if (this.res && !this.res.finalResponseSent) {
|
||||||
|
this.logger.info(`Sending ${err.status} response to SBC due to SipError`);
|
||||||
|
this.res.send(err.status, {
|
||||||
|
headers: {
|
||||||
|
'X-Reason': `endpoint allocation failure: ${err.reason || 'Endpoint Allocation Failed'}`,
|
||||||
|
...(sipReasonHeader && {'Reason': sipReasonHeader})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
this._notifyCallStatusChange({
|
||||||
|
callStatus: CallStatus.Failed,
|
||||||
|
sipStatus: err.status,
|
||||||
|
sipReason: err.reason || 'Endpoint Allocation Failed'
|
||||||
|
});
|
||||||
|
this._callReleased();
|
||||||
|
}
|
||||||
|
}
|
||||||
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
|
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2972,8 +3042,7 @@ Duration=${duration} `
|
|||||||
|
|
||||||
// manage record all call.
|
// manage record all call.
|
||||||
if (callStatus === CallStatus.InProgress) {
|
if (callStatus === CallStatus.InProgress) {
|
||||||
if (this.accountInfo.account.record_all_calls ||
|
if (this.accountInfo.account.record_all_calls || this.application.record_all_calls) {
|
||||||
this.application.record_all_calls) {
|
|
||||||
this.backgroundTaskManager.newTask('record');
|
this.backgroundTaskManager.newTask('record');
|
||||||
}
|
}
|
||||||
} else if (callStatus == CallStatus.Completed) {
|
} else if (callStatus == CallStatus.Completed) {
|
||||||
|
|||||||
@@ -60,6 +60,19 @@ class InboundCallSession extends CallSession {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
else if (this._endpointAllocationError) {
|
||||||
|
// Propagate SIP error from endpoint allocation failure back to the client
|
||||||
|
const {status, reason, sipReasonHeader} = this._endpointAllocationError;
|
||||||
|
this.rootSpan.setAttributes({'call.termination': `endpoint allocation SIP error ${status}`});
|
||||||
|
this.logger.info({endpointAllocationError: this._endpointAllocationError},
|
||||||
|
`InboundCallSession:_onTasksDone generating ${status} due to endpoint allocation failure`);
|
||||||
|
this.res.send(status, {
|
||||||
|
headers: {
|
||||||
|
'X-Reason': `endpoint allocation failure: ${reason}`,
|
||||||
|
...(sipReasonHeader && {'Reason': sipReasonHeader})
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
|
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
|
||||||
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||||
|
|||||||
@@ -195,6 +195,9 @@ class TaskDial extends Task {
|
|||||||
async exec(cs) {
|
async exec(cs) {
|
||||||
await super.exec(cs);
|
await super.exec(cs);
|
||||||
|
|
||||||
|
/* capture whether A leg was already answered before this dial task started */
|
||||||
|
this._aLegAlreadyAnswered = !!cs.dlg;
|
||||||
|
|
||||||
if (this.data.anchorMedia && this.data.exitMediaPath) {
|
if (this.data.anchorMedia && this.data.exitMediaPath) {
|
||||||
this.logger.info('Dial:exec - incompatible anchorMedia and exitMediaPath are both set, will obey anchorMedia');
|
this.logger.info('Dial:exec - incompatible anchorMedia and exitMediaPath are both set, will obey anchorMedia');
|
||||||
delete this.data.exitMediaPath;
|
delete this.data.exitMediaPath;
|
||||||
@@ -550,7 +553,7 @@ class TaskDial extends Task {
|
|||||||
let sbcAddress = this.proxy || getSBC();
|
let sbcAddress = this.proxy || getSBC();
|
||||||
const teamsInfo = {};
|
const teamsInfo = {};
|
||||||
let fqdn;
|
let fqdn;
|
||||||
const forwardPAI = this.forwardPAI ?? JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
|
const forwardPAI = this.forwardPAI ?? !JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
|
||||||
this.logger.debug(forwardPAI, 'forwardPAI value');
|
this.logger.debug(forwardPAI, 'forwardPAI value');
|
||||||
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
||||||
this.headers = {
|
this.headers = {
|
||||||
@@ -872,8 +875,12 @@ class TaskDial extends Task {
|
|||||||
this.sd = sd;
|
this.sd = sd;
|
||||||
this.callSid = sd.callSid;
|
this.callSid = sd.callSid;
|
||||||
if (this.earlyMedia) {
|
if (this.earlyMedia) {
|
||||||
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
if (this._aLegAlreadyAnswered) {
|
||||||
await cs.propagateAnswer();
|
debug('Dial:_selectSingleDial A leg was already answered, skipping propagateAnswer');
|
||||||
|
} else {
|
||||||
|
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
||||||
|
await cs.propagateAnswer();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (this.timeLimit) {
|
if (this.timeLimit) {
|
||||||
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
|
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
|
||||||
|
|||||||
@@ -500,6 +500,10 @@ class TaskGather extends SttTask {
|
|||||||
this.addCustomEventListener(ep, GladiaTranscriptionEvents.ConnectFailure,
|
this.addCustomEventListener(ep, GladiaTranscriptionEvents.ConnectFailure,
|
||||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||||
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
this.addCustomEventListener(ep, GladiaTranscriptionEvents.Error, this._onVendorError.bind(this, cs, ep));
|
||||||
|
// gladia require unique url for each session
|
||||||
|
const {host, path} = await this.createGladiaLiveSession();
|
||||||
|
opts.GLADIA_SPEECH_HOST = host;
|
||||||
|
opts.GLADIA_SPEECH_PATH = path;
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case 'soniox':
|
case 'soniox':
|
||||||
@@ -881,7 +885,7 @@ class TaskGather extends SttTask {
|
|||||||
this._fillerNoiseOn = false; // in a race, if we just started audio it may sneak through here
|
this._fillerNoiseOn = false; // in a race, if we just started audio it may sneak through here
|
||||||
this.ep.api('uuid_break', this.ep.uuid)
|
this.ep.api('uuid_break', this.ep.uuid)
|
||||||
.catch((err) => this.logger.info(err, 'Error killing audio'));
|
.catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||||
cs.clearTtsStream();
|
if (cs.isTtsStreamEnabled) cs.clearTtsStream();
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1318,6 +1322,8 @@ class TaskGather extends SttTask {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.resolved = true;
|
this.resolved = true;
|
||||||
|
// gather is resolved, prevent any further transcription events while resolve in progress
|
||||||
|
this.removeCustomEventListeners();
|
||||||
// If bargin is false and ws application return ack to verb:hook
|
// If bargin is false and ws application return ack to verb:hook
|
||||||
// the gather should not play any audio
|
// the gather should not play any audio
|
||||||
this._killAudio(this.cs);
|
this._killAudio(this.cs);
|
||||||
|
|||||||
@@ -36,6 +36,9 @@ class TaskLlmGoogle_S2S extends Task {
|
|||||||
this.model = this.parent.model || 'models/gemini-2.0-flash-live-001';
|
this.model = this.parent.model || 'models/gemini-2.0-flash-live-001';
|
||||||
this.auth = this.parent.auth;
|
this.auth = this.parent.auth;
|
||||||
this.connectionOptions = this.parent.connectOptions;
|
this.connectionOptions = this.parent.connectOptions;
|
||||||
|
const {host, version} = this.connectionOptions || {};
|
||||||
|
this.host = host;
|
||||||
|
this.version = version;
|
||||||
|
|
||||||
const {apiKey} = this.auth || {};
|
const {apiKey} = this.auth || {};
|
||||||
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
|
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
|
||||||
@@ -46,7 +49,7 @@ class TaskLlmGoogle_S2S extends Task {
|
|||||||
this.eventHook = this.data.eventHook;
|
this.eventHook = this.data.eventHook;
|
||||||
this.toolHook = this.data.toolHook;
|
this.toolHook = this.data.toolHook;
|
||||||
|
|
||||||
const {setup} = this.data.llmOptions;
|
const {setup, sessionResumption} = this.data.llmOptions;
|
||||||
|
|
||||||
if (typeof setup !== 'object') {
|
if (typeof setup !== 'object') {
|
||||||
throw new Error('llmOptions with an initial setup is required for Google S2S');
|
throw new Error('llmOptions with an initial setup is required for Google S2S');
|
||||||
@@ -54,6 +57,7 @@ class TaskLlmGoogle_S2S extends Task {
|
|||||||
this.setup = {
|
this.setup = {
|
||||||
...setup,
|
...setup,
|
||||||
model: this.model,
|
model: this.model,
|
||||||
|
...(sessionResumption && {sessionResumption}),
|
||||||
// make sure output is always audio
|
// make sure output is always audio
|
||||||
generationConfig: {
|
generationConfig: {
|
||||||
...(setup.generationConfig || {}),
|
...(setup.generationConfig || {}),
|
||||||
@@ -138,6 +142,10 @@ class TaskLlmGoogle_S2S extends Task {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const args = [ep.uuid, 'session.create', this.apiKey];
|
const args = [ep.uuid, 'session.create', this.apiKey];
|
||||||
|
if (this.host) {
|
||||||
|
args.push(this.host);
|
||||||
|
if (this.version) args.push(this.version);
|
||||||
|
}
|
||||||
await this._api(ep, args);
|
await this._api(ep, args);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');
|
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');
|
||||||
|
|||||||
@@ -146,8 +146,9 @@ class TaskLlmUltravox_S2S extends Task {
|
|||||||
return data;
|
return data;
|
||||||
}
|
}
|
||||||
|
|
||||||
_unregisterHandlers() {
|
_unregisterHandlers(ep) {
|
||||||
this.removeCustomEventListeners();
|
this.removeCustomEventListeners();
|
||||||
|
ep.removeAllListeners('dtmf');
|
||||||
}
|
}
|
||||||
|
|
||||||
_registerHandlers(ep) {
|
_registerHandlers(ep) {
|
||||||
@@ -155,6 +156,7 @@ class TaskLlmUltravox_S2S extends Task {
|
|||||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
this.addCustomEventListener(ep, LlmEvents_Ultravox.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.Disconnect, this._onDisconnect.bind(this, ep));
|
this.addCustomEventListener(ep, LlmEvents_Ultravox.Disconnect, this._onDisconnect.bind(this, ep));
|
||||||
this.addCustomEventListener(ep, LlmEvents_Ultravox.ServerEvent, this._onServerEvent.bind(this, ep));
|
this.addCustomEventListener(ep, LlmEvents_Ultravox.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||||
|
ep.on('dtmf', this._onDtmf.bind(this, ep));
|
||||||
}
|
}
|
||||||
|
|
||||||
async _startListening(cs, ep) {
|
async _startListening(cs, ep) {
|
||||||
@@ -189,7 +191,7 @@ class TaskLlmUltravox_S2S extends Task {
|
|||||||
/* note: the parent llm verb started the span, which is why this is necessary */
|
/* note: the parent llm verb started the span, which is why this is necessary */
|
||||||
await this.parent.performAction(this.results);
|
await this.parent.performAction(this.results);
|
||||||
|
|
||||||
this._unregisterHandlers();
|
this._unregisterHandlers(ep);
|
||||||
}
|
}
|
||||||
|
|
||||||
async kill(cs) {
|
async kill(cs) {
|
||||||
@@ -346,6 +348,18 @@ class TaskLlmUltravox_S2S extends Task {
|
|||||||
excludeEvents: this.excludeEvents
|
excludeEvents: this.excludeEvents
|
||||||
}, 'TaskLlmUltravox_S2S:_populateEvents');
|
}, 'TaskLlmUltravox_S2S:_populateEvents');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_onDtmf(ep, evt) {
|
||||||
|
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onDtmf - DTMF received');
|
||||||
|
const {dtmf} = evt;
|
||||||
|
const data = {
|
||||||
|
type: 'user_text_message',
|
||||||
|
text: `DTMF received: ${dtmf}`,
|
||||||
|
urgency: 'immediate'
|
||||||
|
};
|
||||||
|
this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)])
|
||||||
|
.catch((err) => this.logger.info({err, evt}, 'TaskLlmUltravox_S2S:_onDtmf - Error sending DTMF as text message'));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = TaskLlmUltravox_S2S;
|
module.exports = TaskLlmUltravox_S2S;
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
const Task = require('./task');
|
const Task = require('./task');
|
||||||
const {TaskName} = require('../utils/constants');
|
const {TaskName} = require('../utils/constants');
|
||||||
const WsRequestor = require('../utils/ws-requestor');
|
const WsRequestor = require('../utils/ws-requestor');
|
||||||
const URL = require('url');
|
|
||||||
const HttpRequestor = require('../utils/http-requestor');
|
const HttpRequestor = require('../utils/http-requestor');
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -10,6 +9,7 @@ const HttpRequestor = require('../utils/http-requestor');
|
|||||||
class TaskRedirect extends Task {
|
class TaskRedirect extends Task {
|
||||||
constructor(logger, opts) {
|
constructor(logger, opts) {
|
||||||
super(logger, opts);
|
super(logger, opts);
|
||||||
|
this.statusHook = opts.statusHook || false;
|
||||||
}
|
}
|
||||||
|
|
||||||
get name() { return TaskName.Redirect; }
|
get name() { return TaskName.Redirect; }
|
||||||
@@ -33,7 +33,7 @@ class TaskRedirect extends Task {
|
|||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
const baseUrl = this.cs.application.requestor.baseUrl;
|
const baseUrl = this.cs.application.requestor.baseUrl;
|
||||||
const newUrl = URL.parse(this.actionHook);
|
const newUrl = new URL(this.actionHook);
|
||||||
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
|
const newBaseUrl = newUrl.protocol + '//' + newUrl.host;
|
||||||
if (baseUrl != newBaseUrl) {
|
if (baseUrl != newBaseUrl) {
|
||||||
try {
|
try {
|
||||||
@@ -47,6 +47,30 @@ class TaskRedirect extends Task {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* update the notifier if a new statusHook was provided */
|
||||||
|
if (this.statusHook) {
|
||||||
|
this.logger.info(`TaskRedirect updating statusHook to ${this.statusHook}`);
|
||||||
|
try {
|
||||||
|
const oldNotifier = cs.application.notifier;
|
||||||
|
const isStatusHookAbsolute = cs.notifier?._isAbsoluteUrl(this.statusHook);
|
||||||
|
if (isStatusHookAbsolute) {
|
||||||
|
if (cs.notifier instanceof WsRequestor) {
|
||||||
|
cs.application.notifier = new WsRequestor(this.logger, cs.accountSid, {url: this.statusHook},
|
||||||
|
cs.accountInfo.account.webhook_secret);
|
||||||
|
} else {
|
||||||
|
cs.application.notifier = new HttpRequestor(this.logger, cs.accountSid, {url: this.statusHook},
|
||||||
|
cs.accountInfo.account.webhook_secret);
|
||||||
|
}
|
||||||
|
if (oldNotifier?.close) oldNotifier.close();
|
||||||
|
}
|
||||||
|
/* update the call_status_hook URL that gets passed to the notifier */
|
||||||
|
cs.application.call_status_hook = this.statusHook;
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.info(err, `TaskRedirect error updating statusHook to ${this.statusHook}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
await this.performAction();
|
await this.performAction();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
const assert = require('assert');
|
const assert = require('assert');
|
||||||
const TtsTask = require('./tts-task');
|
const TtsTask = require('./tts-task');
|
||||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||||
|
const {JAMBONES_SAY_CHUNK_SIZE} = require('../config');
|
||||||
const pollySSMLSplit = require('polly-ssml-split');
|
const pollySSMLSplit = require('polly-ssml-split');
|
||||||
const { SpeechCredentialError, NonFatalTaskError } = require('../utils/error');
|
const { SpeechCredentialError, NonFatalTaskError } = require('../utils/error');
|
||||||
const { sleepFor } = require('../utils/helpers');
|
const { sleepFor } = require('../utils/helpers');
|
||||||
@@ -31,7 +32,7 @@ const isMatchingEvent = (logger, filename, playbackId, evt) => {
|
|||||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||||
// As The text can be used for tts streaming, we need to break lengthy text into smaller chunks
|
// As The text can be used for tts streaming, we need to break lengthy text into smaller chunks
|
||||||
// HIGH_WATER_BUFFER_SIZE defined in tts-streaming-buffer.js
|
// HIGH_WATER_BUFFER_SIZE defined in tts-streaming-buffer.js
|
||||||
const chunkSize = 900;
|
const chunkSize = JAMBONES_SAY_CHUNK_SIZE;
|
||||||
const isSSML = text.startsWith('<speak>');
|
const isSSML = text.startsWith('<speak>');
|
||||||
const options = {
|
const options = {
|
||||||
softLimit: 100,
|
softLimit: 100,
|
||||||
|
|||||||
@@ -203,26 +203,14 @@ class SttTask extends Task {
|
|||||||
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
|
if (cs.hasGlobalSttPunctuation && !this.data.recognizer.punctuation) {
|
||||||
this.data.recognizer.punctuation = cs.globalSttPunctuation;
|
this.data.recognizer.punctuation = cs.globalSttPunctuation;
|
||||||
}
|
}
|
||||||
if (this.vendor === 'gladia') {
|
|
||||||
const { api_key, region } = this.sttCredentials;
|
|
||||||
const {url} = await this.createGladiaLiveSession({
|
|
||||||
api_key, region,
|
|
||||||
model: this.data.recognizer.model || 'solaria-1',
|
|
||||||
options: this.data.recognizer.gladiaOptions || {}
|
|
||||||
});
|
|
||||||
const {host, pathname, search} = new URL(url);
|
|
||||||
this.sttCredentials.host = host;
|
|
||||||
this.sttCredentials.path = `${pathname}${search}`;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
async createGladiaLiveSession({
|
async createGladiaLiveSession() {
|
||||||
api_key,
|
const { api_key, region = 'us-west' } = this.sttCredentials;
|
||||||
region = 'us-west',
|
const model = this.data.recognizer.model || 'solaria-1';
|
||||||
model = 'solaria-1',
|
const options = this.data.recognizer.gladiaOptions || {};
|
||||||
options = {},
|
|
||||||
}) {
|
|
||||||
const url = `https://api.gladia.io/v2/live?region=${region}`;
|
const url = `https://api.gladia.io/v2/live?region=${region}`;
|
||||||
const response = await fetch(url, {
|
const response = await fetch(url, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -252,7 +240,9 @@ class SttTask extends Task {
|
|||||||
|
|
||||||
const data = await response.json();
|
const data = await response.json();
|
||||||
this.logger.debug({url: data.url}, 'Gladia Call registered');
|
this.logger.debug({url: data.url}, 'Gladia Call registered');
|
||||||
return data;
|
|
||||||
|
const {host, pathname, search} = new URL(data.url);
|
||||||
|
return {host, path: `${pathname}${search}`};
|
||||||
}
|
}
|
||||||
|
|
||||||
addCustomEventListener(ep, event, handler) {
|
addCustomEventListener(ep, event, handler) {
|
||||||
@@ -286,6 +276,7 @@ class SttTask extends Task {
|
|||||||
account_sid: cs.accountSid,
|
account_sid: cs.accountSid,
|
||||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||||
vendor,
|
vendor,
|
||||||
|
label,
|
||||||
target_sid: cs.callSid
|
target_sid: cs.callSid
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
||||||
// the ASR might have fallback configuration, should not done task here.
|
// the ASR might have fallback configuration, should not done task here.
|
||||||
@@ -486,6 +477,7 @@ class SttTask extends Task {
|
|||||||
message: 'STT failure reported by vendor',
|
message: 'STT failure reported by vendor',
|
||||||
detail: evt.error,
|
detail: evt.error,
|
||||||
vendor: this.vendor,
|
vendor: this.vendor,
|
||||||
|
label: this.label,
|
||||||
target_sid: cs.callSid
|
target_sid: cs.callSid
|
||||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
||||||
}
|
}
|
||||||
@@ -499,6 +491,7 @@ class SttTask extends Task {
|
|||||||
alert_type: AlertType.STT_FAILURE,
|
alert_type: AlertType.STT_FAILURE,
|
||||||
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
|
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
|
||||||
vendor: this.vendor,
|
vendor: this.vendor,
|
||||||
|
label: this.label,
|
||||||
target_sid: cs.callSid
|
target_sid: cs.callSid
|
||||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -459,6 +459,14 @@ class TaskTranscribe extends SttTask {
|
|||||||
else if (this.data.recognizer?.hints?.length > 0) {
|
else if (this.data.recognizer?.hints?.length > 0) {
|
||||||
prompt = this.data.recognizer?.hints.join(', ');
|
prompt = this.data.recognizer?.hints.join(', ');
|
||||||
}
|
}
|
||||||
|
} else if (this.vendor === 'gladia') {
|
||||||
|
// gladia require unique url for each session
|
||||||
|
const {host, path} = await this.createGladiaLiveSession();
|
||||||
|
await ep.set({
|
||||||
|
GLADIA_SPEECH_HOST: host,
|
||||||
|
GLADIA_SPEECH_PATH: path,
|
||||||
|
})
|
||||||
|
.catch((err) => this.logger.info(err, 'Error setting channel variables'));
|
||||||
}
|
}
|
||||||
|
|
||||||
await ep.startTranscription({
|
await ep.startTranscription({
|
||||||
|
|||||||
@@ -31,8 +31,9 @@ class TtsTask extends Task {
|
|||||||
this.synthesizer = this.data.synthesizer || {};
|
this.synthesizer = this.data.synthesizer || {};
|
||||||
this.disableTtsCache = this.data.disableTtsCache;
|
this.disableTtsCache = this.data.disableTtsCache;
|
||||||
this.options = this.synthesizer.options || {};
|
this.options = this.synthesizer.options || {};
|
||||||
this.instructions = this.data.instructions;
|
this.instructions = this.data.instructions || this.options.instructions;
|
||||||
this.playbackIds = [];
|
this.playbackIds = [];
|
||||||
|
this.useGeminiTts = this.options.useGeminiTts;
|
||||||
}
|
}
|
||||||
|
|
||||||
getPlaybackId(offset) {
|
getPlaybackId(offset) {
|
||||||
@@ -156,6 +157,13 @@ class TtsTask extends Task {
|
|||||||
...(reduceLatency && {RIMELABS_TTS_STREAMING_REDUCE_LATENCY: reduceLatency})
|
...(reduceLatency && {RIMELABS_TTS_STREAMING_REDUCE_LATENCY: reduceLatency})
|
||||||
};
|
};
|
||||||
break;
|
break;
|
||||||
|
case 'google':
|
||||||
|
obj = {
|
||||||
|
GOOGLE_TTS_LANGUAGE_CODE: language,
|
||||||
|
GOOGLE_TTS_VOICE_NAME: voice,
|
||||||
|
GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(credentials.credentials)
|
||||||
|
};
|
||||||
|
break;
|
||||||
default:
|
default:
|
||||||
if (vendor.startsWith('custom:')) {
|
if (vendor.startsWith('custom:')) {
|
||||||
const use_tls = custom_tts_streaming_url.startsWith('wss://');
|
const use_tls = custom_tts_streaming_url.startsWith('wss://');
|
||||||
@@ -242,6 +250,8 @@ class TtsTask extends Task {
|
|||||||
}
|
}
|
||||||
} else if (vendor === 'cartesia') {
|
} else if (vendor === 'cartesia') {
|
||||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||||
|
} else if (vendor === 'google') {
|
||||||
|
this.model = this.options.model || credentials.credentials.model_id;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.model_id = credentials.model_id;
|
this.model_id = credentials.model_id;
|
||||||
@@ -274,6 +284,7 @@ class TtsTask extends Task {
|
|||||||
account_sid,
|
account_sid,
|
||||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||||
vendor,
|
vendor,
|
||||||
|
label,
|
||||||
target_sid: cs.callSid
|
target_sid: cs.callSid
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||||
throw new SpeechCredentialError('no provisioned speech credentials for TTS');
|
throw new SpeechCredentialError('no provisioned speech credentials for TTS');
|
||||||
@@ -360,6 +371,7 @@ class TtsTask extends Task {
|
|||||||
account_sid: cs.accountSid,
|
account_sid: cs.accountSid,
|
||||||
alert_type: AlertType.TTS_FAILURE,
|
alert_type: AlertType.TTS_FAILURE,
|
||||||
vendor,
|
vendor,
|
||||||
|
label,
|
||||||
detail: err.message,
|
detail: err.message,
|
||||||
target_sid: cs.callSid
|
target_sid: cs.callSid
|
||||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||||
|
|||||||
@@ -118,6 +118,13 @@ class ActionHookDelayProcessor extends Emitter {
|
|||||||
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer');
|
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer');
|
||||||
this._noResponseTimer = null;
|
this._noResponseTimer = null;
|
||||||
|
|
||||||
|
/* check if endpoint is still available (call may have ended) */
|
||||||
|
if (!this.ep) {
|
||||||
|
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer: endpoint is null, call may have ended');
|
||||||
|
this._active = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
/* get the next play or say action */
|
/* get the next play or say action */
|
||||||
const verb = this.actions[this._retryCount % this.actions.length];
|
const verb = this.actions[this._retryCount % this.actions.length];
|
||||||
|
|
||||||
@@ -129,8 +136,8 @@ class ActionHookDelayProcessor extends Emitter {
|
|||||||
this._taskInProgress.exec(this.cs, {ep: this.ep}).catch((err) => {
|
this._taskInProgress.exec(this.cs, {ep: this.ep}).catch((err) => {
|
||||||
this.logger.info(`ActionHookDelayProcessor#_onNoResponseTimer: error playing file: ${err.message}`);
|
this.logger.info(`ActionHookDelayProcessor#_onNoResponseTimer: error playing file: ${err.message}`);
|
||||||
this._taskInProgress = null;
|
this._taskInProgress = null;
|
||||||
this.ep.removeAllListeners('playback-start');
|
this.ep?.removeAllListeners('playback-start');
|
||||||
this.ep.removeAllListeners('playback-stop');
|
this.ep?.removeAllListeners('playback-stop');
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
this.logger.info(err, 'ActionHookDelayProcessor#_onNoResponseTimer: error starting action');
|
this.logger.info(err, 'ActionHookDelayProcessor#_onNoResponseTimer: error starting action');
|
||||||
|
|||||||
@@ -405,19 +405,21 @@ module.exports = (logger) => {
|
|||||||
if (ep.amd) {
|
if (ep.amd) {
|
||||||
vendor = ep.amd.vendor;
|
vendor = ep.amd.vendor;
|
||||||
ep.amd.stopAllTimers();
|
ep.amd.stopAllTimers();
|
||||||
|
try {
|
||||||
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(GoogleTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
|
ep.removeListener(GoogleTranscriptionEvents.EndOfUtterance, ep.amd.EndOfUtteranceHandler);
|
||||||
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(AwsTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(AzureTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
|
ep.removeListener(AzureTranscriptionEvents.NoSpeechDetected, ep.amd.noSpeechHandler);
|
||||||
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(NuanceTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(DeepgramTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(SonioxTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(IbmTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(NvidiaTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
ep.removeListener(JambonzTranscriptionEvents.Transcription, ep.amd.transcriptionHandler);
|
||||||
|
} catch (error) {
|
||||||
|
logger.error('Unable to Remove AMD Listener', error);
|
||||||
|
}
|
||||||
ep.amd = null;
|
ep.amd = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -135,26 +135,24 @@ class BackgroundTaskManager extends Emitter {
|
|||||||
|
|
||||||
// Initiate Record
|
// Initiate Record
|
||||||
async _initRecord() {
|
async _initRecord() {
|
||||||
if (this.cs.accountInfo.account.record_all_calls || this.cs.application.record_all_calls) {
|
if (!JAMBONZ_RECORD_WS_BASE_URL || !this.cs.accountInfo.account.bucket_credential) {
|
||||||
if (!JAMBONZ_RECORD_WS_BASE_URL || !this.cs.accountInfo.account.bucket_credential) {
|
this.logger.error('_initRecord: invalid cfg - missing JAMBONZ_RECORD_WS_BASE_URL or bucket config');
|
||||||
this.logger.error('_initRecord: invalid cfg - missing JAMBONZ_RECORD_WS_BASE_URL or bucket config');
|
return undefined;
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
const listenOpts = {
|
|
||||||
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.cs.accountInfo.account.bucket_credential.vendor}`,
|
|
||||||
disableBidirectionalAudio: true,
|
|
||||||
mixType : 'stereo',
|
|
||||||
passDtmf: true
|
|
||||||
};
|
|
||||||
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
|
|
||||||
listenOpts.wsAuth = {
|
|
||||||
username: JAMBONZ_RECORD_WS_USERNAME,
|
|
||||||
password: JAMBONZ_RECORD_WS_PASSWORD
|
|
||||||
};
|
|
||||||
}
|
|
||||||
this.logger.debug({listenOpts}, '_initRecord: enabling listen');
|
|
||||||
return await this._initListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record', true, 'record');
|
|
||||||
}
|
}
|
||||||
|
const listenOpts = {
|
||||||
|
url: `${JAMBONZ_RECORD_WS_BASE_URL}/record/${this.cs.accountInfo.account.bucket_credential.vendor}`,
|
||||||
|
disableBidirectionalAudio: true,
|
||||||
|
mixType : 'stereo',
|
||||||
|
passDtmf: true
|
||||||
|
};
|
||||||
|
if (JAMBONZ_RECORD_WS_USERNAME && JAMBONZ_RECORD_WS_PASSWORD) {
|
||||||
|
listenOpts.wsAuth = {
|
||||||
|
username: JAMBONZ_RECORD_WS_USERNAME,
|
||||||
|
password: JAMBONZ_RECORD_WS_PASSWORD
|
||||||
|
};
|
||||||
|
}
|
||||||
|
this.logger.debug({listenOpts}, '_initRecord: enabling listen');
|
||||||
|
return await this._initListen({verb: 'listen', ...listenOpts}, 'jambonz-session-record', true, 'record');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initiate Transcribe
|
// Initiate Transcribe
|
||||||
|
|||||||
@@ -311,6 +311,11 @@
|
|||||||
"ConnectFailure": "deepgram_tts_streaming::connect_failed",
|
"ConnectFailure": "deepgram_tts_streaming::connect_failed",
|
||||||
"Connect": "deepgram_tts_streaming::connect"
|
"Connect": "deepgram_tts_streaming::connect"
|
||||||
},
|
},
|
||||||
|
"GoogleTtsStreamingEvents": {
|
||||||
|
"Empty": "google_tts_streaming::empty",
|
||||||
|
"ConnectFailure": "google_tts_streaming::connect_failed",
|
||||||
|
"Connect": "google_tts_streaming::connect"
|
||||||
|
},
|
||||||
"CartesiaTtsStreamingEvents": {
|
"CartesiaTtsStreamingEvents": {
|
||||||
"Empty": "cartesia_tts_streaming::empty",
|
"Empty": "cartesia_tts_streaming::empty",
|
||||||
"ConnectFailure": "cartesia_tts_streaming::connect_failed",
|
"ConnectFailure": "cartesia_tts_streaming::connect_failed",
|
||||||
|
|||||||
@@ -100,6 +100,30 @@ module.exports = (logger) => {
|
|||||||
else if (K8S) {
|
else if (K8S) {
|
||||||
lifecycleEmitter.scaleIn = () => process.exit(0);
|
lifecycleEmitter.scaleIn = () => process.exit(0);
|
||||||
}
|
}
|
||||||
|
else {
|
||||||
|
process.on('SIGUSR1', () => {
|
||||||
|
logger.info('received SIGUSR1: begin drying up calls for scale-in');
|
||||||
|
dryUpCalls = true;
|
||||||
|
|
||||||
|
const {srf} = require('../..');
|
||||||
|
const {writeSystemAlerts} = srf.locals;
|
||||||
|
if (writeSystemAlerts) {
|
||||||
|
const {SystemState, FEATURE_SERVER} = require('./constants');
|
||||||
|
writeSystemAlerts({
|
||||||
|
system_component: FEATURE_SERVER,
|
||||||
|
state : SystemState.GracefulShutdownInProgress,
|
||||||
|
fields : {
|
||||||
|
detail: `feature-server with process_id ${process.pid} shutdown in progress`,
|
||||||
|
host: srf.locals?.ipv4
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
pingProxies(srf);
|
||||||
|
|
||||||
|
// Note: in response to SIGUSR1 we start drying up but do not exit when calls reach zero.
|
||||||
|
// This is to allow external scripts that sent the signal to manage the lifecycle.
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async function pingProxies(srf) {
|
async function pingProxies(srf) {
|
||||||
|
|||||||
@@ -127,7 +127,6 @@ class SttLatencyCalculator extends Emitter {
|
|||||||
|
|
||||||
calculateLatency() {
|
calculateLatency() {
|
||||||
if (!this.isRunning) {
|
if (!this.isRunning) {
|
||||||
this.logger.debug('Latency calculator is not running, cannot calculate latency, returning default values');
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1085,13 +1085,6 @@ module.exports = (logger) => {
|
|||||||
...(keyterms && keyterms.length > 0 && {DEEPGRAMFLUX_SPEECH_KEYTERMS: keyterms.join(',')}),
|
...(keyterms && keyterms.length > 0 && {DEEPGRAMFLUX_SPEECH_KEYTERMS: keyterms.join(',')}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if ('gladia' === vendor) {
|
|
||||||
const {host, path} = sttCredentials;
|
|
||||||
opts = {
|
|
||||||
GLADIA_SPEECH_HOST: host,
|
|
||||||
GLADIA_SPEECH_PATH: path,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
else if ('soniox' === vendor) {
|
else if ('soniox' === vendor) {
|
||||||
const {sonioxOptions = {}} = rOpts;
|
const {sonioxOptions = {}} = rOpts;
|
||||||
const {storage = {}} = sonioxOptions;
|
const {storage = {}} = sonioxOptions;
|
||||||
@@ -1317,6 +1310,9 @@ module.exports = (logger) => {
|
|||||||
...(openaiOptions.turn_detection.silence_duration_ms && {
|
...(openaiOptions.turn_detection.silence_duration_ms && {
|
||||||
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
|
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
|
||||||
}),
|
}),
|
||||||
|
...(openaiOptions.turn_detection.eagerness && {
|
||||||
|
OPENAI_TURN_DETECTION_EAGERNESS: openaiOptions.turn_detection.eagerness
|
||||||
|
})
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1382,7 +1378,9 @@ module.exports = (logger) => {
|
|||||||
speechmaticsOptions.transcription_config.audio_filtering_config.volume_threshold}),
|
speechmaticsOptions.transcription_config.audio_filtering_config.volume_threshold}),
|
||||||
...(speechmaticsOptions.transcription_config?.transcript_filtering_config?.remove_disfluencies &&
|
...(speechmaticsOptions.transcription_config?.transcript_filtering_config?.remove_disfluencies &&
|
||||||
{SPEECHMATICS_REMOVE_DISFLUENCIES:
|
{SPEECHMATICS_REMOVE_DISFLUENCIES:
|
||||||
speechmaticsOptions.transcription_config.transcript_filtering_config.remove_disfluencies})
|
speechmaticsOptions.transcription_config.transcript_filtering_config.remove_disfluencies}),
|
||||||
|
SPEECHMATICS_END_OF_UTTERANCE_SILENCE_TRIGGER:
|
||||||
|
speechmaticsOptions.transcription_config?.conversation_config?.end_of_utterance_silence_trigger || 0.5
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
else if (vendor.startsWith('custom:')) {
|
else if (vendor.startsWith('custom:')) {
|
||||||
|
|||||||
@@ -163,7 +163,6 @@ class TtsStreamingBuffer extends Emitter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
clear() {
|
clear() {
|
||||||
this.logger.debug('TtsStreamingBuffer:clear');
|
|
||||||
if (this._connectionStatus !== TtsStreamingConnectionStatus.Connected) return;
|
if (this._connectionStatus !== TtsStreamingConnectionStatus.Connected) return;
|
||||||
clearTimeout(this.timer);
|
clearTimeout(this.timer);
|
||||||
this._api(this.ep, [this.ep.uuid, 'clear']).catch((err) =>
|
this._api(this.ep, [this.ep.uuid, 'clear']).catch((err) =>
|
||||||
@@ -422,6 +421,7 @@ class TtsStreamingBuffer extends Emitter {
|
|||||||
'cartesia',
|
'cartesia',
|
||||||
'elevenlabs',
|
'elevenlabs',
|
||||||
'rimelabs',
|
'rimelabs',
|
||||||
|
'google',
|
||||||
'custom'
|
'custom'
|
||||||
].forEach((vendor) => {
|
].forEach((vendor) => {
|
||||||
const eventClassName = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}TtsStreamingEvents`;
|
const eventClassName = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}TtsStreamingEvents`;
|
||||||
@@ -437,7 +437,15 @@ class TtsStreamingBuffer extends Emitter {
|
|||||||
|
|
||||||
const findSentenceBoundary = (text, limit) => {
|
const findSentenceBoundary = (text, limit) => {
|
||||||
// Look for punctuation or double newline that signals sentence end.
|
// Look for punctuation or double newline that signals sentence end.
|
||||||
const sentenceEndRegex = /[.!?](?=\s|$)|\n\n/g;
|
// Includes:
|
||||||
|
// - ASCII: . ! ?
|
||||||
|
// - Arabic: ؟ (question mark), ۔ (full stop)
|
||||||
|
// - Japanese: 。 (full stop), !, ? (full-width exclamation/question)
|
||||||
|
//
|
||||||
|
// For languages that use spaces between sentences, we still require
|
||||||
|
// whitespace or end-of-string after the mark. For Japanese (no spaces),
|
||||||
|
// we treat the punctuation itself as a boundary regardless of following char.
|
||||||
|
const sentenceEndRegex = /[.!?؟۔](?=\s|$)|[。!?]|\n\n/g;
|
||||||
let lastSentenceBoundary = -1;
|
let lastSentenceBoundary = -1;
|
||||||
let match;
|
let match;
|
||||||
while ((match = sentenceEndRegex.exec(text)) && match.index < limit) {
|
while ((match = sentenceEndRegex.exec(text)) && match.index < limit) {
|
||||||
|
|||||||
5661
package-lock.json
generated
5661
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,10 @@
|
|||||||
"@jambonz/http-health-check": "^0.0.1",
|
"@jambonz/http-health-check": "^0.0.1",
|
||||||
"@jambonz/mw-registrar": "^0.2.7",
|
"@jambonz/mw-registrar": "^0.2.7",
|
||||||
"@jambonz/realtimedb-helpers": "^0.8.15",
|
"@jambonz/realtimedb-helpers": "^0.8.15",
|
||||||
"@jambonz/speech-utils": "^0.2.26",
|
"@jambonz/speech-utils": "^0.2.30",
|
||||||
"@jambonz/stats-collector": "^0.1.10",
|
"@jambonz/stats-collector": "^0.1.10",
|
||||||
"@jambonz/time-series": "^0.2.14",
|
"@jambonz/time-series": "^0.2.15",
|
||||||
"@jambonz/verb-specifications": "^0.0.122",
|
"@jambonz/verb-specifications": "^0.0.125",
|
||||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||||
"@opentelemetry/api": "^1.8.0",
|
"@opentelemetry/api": "^1.8.0",
|
||||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||||
|
|||||||
Reference in New Issue
Block a user