mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
21 Commits
v0.8.5-25-
...
mod_google
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b7f6c5b20c | ||
|
|
ad5df4b7f7 | ||
|
|
bd002ede48 | ||
|
|
1a2aa91973 | ||
|
|
e322b7d8d3 | ||
|
|
7da11df88e | ||
|
|
09cf1345f6 | ||
|
|
2595f527ff | ||
|
|
1d77c0cd20 | ||
|
|
9eab81268b | ||
|
|
ecf3d140d6 | ||
|
|
4a52be9171 | ||
|
|
9b722ae36d | ||
|
|
370b046fac | ||
|
|
fca391c32e | ||
|
|
043860c4a3 | ||
|
|
a021ee3112 | ||
|
|
8999c85a71 | ||
|
|
72147a8110 | ||
|
|
93d0e41e31 | ||
|
|
5b1d8a8ff3 |
2
.github/workflows/build.yml
vendored
2
.github/workflows/build.yml
vendored
@@ -9,7 +9,7 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
with:
|
||||
node-version: 18
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm run jslint
|
||||
- run: docker pull drachtio/sipp
|
||||
|
||||
@@ -14,6 +14,7 @@ const moment = require('moment');
|
||||
const assert = require('assert');
|
||||
const sessionTracker = require('./session-tracker');
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const parseDecibels = require('../utils/parse-decibels');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const listTaskNames = require('../utils/summarize-tasks');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
@@ -111,13 +112,17 @@ class CallSession extends Emitter {
|
||||
this.requestor.removeAllListeners();
|
||||
this.application.requestor = newRequestor;
|
||||
this.requestor.on('command', this._onCommand.bind(this));
|
||||
this.logger.debug(`CallSession: ${this.callSid} listener count ${this.requestor.listenerCount('command')}`);
|
||||
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
};
|
||||
|
||||
this.requestor.on('command', this._onCommand.bind(this));
|
||||
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
if (!this.isConfirmCallSession) {
|
||||
this.requestor.on('command', this._onCommand.bind(this));
|
||||
this.logger.debug(`CallSession: ${this.callSid} listener count ${this.requestor.listenerCount('command')}`);
|
||||
this.requestor.on('connection-dropped', this._onWsConnectionDropped.bind(this));
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -511,6 +516,18 @@ class CallSession extends Emitter {
|
||||
this.speechSynthesisVoice = this._origSynthesizerSettings.voice;
|
||||
}
|
||||
|
||||
enableFillerNoise(opts) {
|
||||
this._fillerNoise = opts;
|
||||
}
|
||||
|
||||
disableFillerNoise() {
|
||||
this._fillerNoise = null;
|
||||
}
|
||||
|
||||
get fillerNoise() {
|
||||
return this._fillerNoise;
|
||||
}
|
||||
|
||||
async notifyRecordOptions(opts) {
|
||||
const {action} = opts;
|
||||
this.logger.debug({opts}, 'CallSession:notifyRecordOptions');
|
||||
@@ -685,7 +702,7 @@ class CallSession extends Emitter {
|
||||
task = await this.backgroundTaskManager.newTask('bargeIn', gather);
|
||||
task.sticky = autoEnable;
|
||||
// listen to the bargein-done from background manager
|
||||
this.backgroundTaskManager.once('bargeIn-done', () => {
|
||||
this.backgroundTaskManager.on('bargeIn-done', () => {
|
||||
if (this.requestor instanceof WsRequestor) {
|
||||
try {
|
||||
this.kill(true);
|
||||
@@ -698,6 +715,8 @@ class CallSession extends Emitter {
|
||||
}
|
||||
}
|
||||
async disableBotMode() {
|
||||
const task = this.backgroundTaskManager.getTask('bargeIn');
|
||||
if (task) task.sticky = false;
|
||||
this.backgroundTaskManager.stop('bargeIn');
|
||||
}
|
||||
|
||||
@@ -836,6 +855,19 @@ class CallSession extends Emitter {
|
||||
model_id: credential.model_id,
|
||||
options: credential.options
|
||||
};
|
||||
} else if ('playht' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
user_id: credential.user_id,
|
||||
voice_engine: credential.voice_engine,
|
||||
options: credential.options
|
||||
};
|
||||
} else if ('rimelabs' === 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,
|
||||
@@ -1212,7 +1244,15 @@ class CallSession extends Emitter {
|
||||
this.callInfo.customerData = tag;
|
||||
}
|
||||
|
||||
async _lccMuteStatus(callSid, mute) {
|
||||
async _lccConferenceParticipantAction(opts) {
|
||||
const task = this.currentTask;
|
||||
if (!task || TaskName.Conference !== task.name || !this.isInConference) {
|
||||
return this.logger.info('CallSession:_lccConferenceParticipantState - invalid cmd, call is not in conference');
|
||||
}
|
||||
task.doConferenceParticipantAction(this, opts);
|
||||
}
|
||||
|
||||
async _lccMuteStatus(mute, callSid) {
|
||||
// this whole thing requires us to be in a Dial or Conference verb
|
||||
const task = this.currentTask;
|
||||
if (!task || ![TaskName.Dial, TaskName.Conference].includes(task.name)) {
|
||||
@@ -1344,6 +1384,70 @@ Duration=${duration} `
|
||||
task.whisper(tasks, callSid).catch((err) => this.logger.error(err, 'CallSession:_lccWhisper'));
|
||||
}
|
||||
|
||||
async _lccConfig(opts) {
|
||||
this.logger.debug({opts}, 'CallSession:_lccConfig');
|
||||
const t = normalizeJambones(this.logger, [
|
||||
{
|
||||
verb: 'config',
|
||||
...opts
|
||||
}
|
||||
])
|
||||
.map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
const task = t[0];
|
||||
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
|
||||
span.setAttributes({'verb.summary': task.summary});
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
try {
|
||||
await task.exec(this, {ep: this.ep});
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'CallSession:_lccConfig');
|
||||
}
|
||||
task.span.end();
|
||||
}
|
||||
|
||||
async _lccDub(opts, callSid) {
|
||||
this.logger.debug({opts}, `CallSession:_lccDub on call_sid ${callSid}`);
|
||||
const t = normalizeJambones(this.logger, [
|
||||
{
|
||||
verb: 'dub',
|
||||
...opts
|
||||
}
|
||||
])
|
||||
.map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
const task = t[0];
|
||||
const ep = this.currentTask?.name === TaskName.Dial && callSid === this.currentTask?.callSid ?
|
||||
this.currentTask.ep :
|
||||
this.ep;
|
||||
|
||||
const {span, ctx} = this.rootSpan.startChildSpan(`verb:${task.summary}`);
|
||||
span.setAttributes({'verb.summary': task.summary});
|
||||
task.span = span;
|
||||
task.ctx = ctx;
|
||||
try {
|
||||
await task.exec(this, {ep});
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'CallSession:_lccDub');
|
||||
}
|
||||
task.span.end();
|
||||
}
|
||||
|
||||
|
||||
async _lccBoostAudioSignal(opts, callSid) {
|
||||
const ep = this.currentTask?.name === TaskName.Dial && callSid === this.currentTask?.callSid ?
|
||||
this.currentTask.ep :
|
||||
this.ep;
|
||||
const db = parseDecibels(opts);
|
||||
this.logger.info(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
|
||||
const args = [ep.uuid, 'setGain', db];
|
||||
const response = await ep.api('uuid_dub', args);
|
||||
this.logger.info({response}, '_lccBoostAudioSignal: response from freeswitch');
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* perform call hangup by jambonz
|
||||
*/
|
||||
@@ -1374,7 +1478,7 @@ Duration=${duration} `
|
||||
await this._lccTranscribeStatus(opts);
|
||||
}
|
||||
else if (opts.mute_status) {
|
||||
await this._lccMuteStatus(callSid, opts.mute_status === 'mute');
|
||||
await this._lccMuteStatus(opts.mute_status === 'mute', callSid);
|
||||
}
|
||||
else if (opts.conf_hold_status) {
|
||||
await this._lccConfHoldStatus(opts);
|
||||
@@ -1394,6 +1498,15 @@ Duration=${duration} `
|
||||
else if (opts.tag) {
|
||||
return this._lccTag(opts);
|
||||
}
|
||||
else if (opts.conferenceParticipantAction) {
|
||||
return this._lccConferenceParticipantState(opts);
|
||||
}
|
||||
else if (opts.dub) {
|
||||
return this._lccDub(opts);
|
||||
}
|
||||
else if (opts.boostAudioSignal) {
|
||||
return this._lccBoostAudioSignal(opts, callSid);
|
||||
}
|
||||
|
||||
// whisper may be the only thing we are asked to do, or it may that
|
||||
// we are doing a whisper after having muted, paused recording etc..
|
||||
@@ -1485,7 +1598,23 @@ Duration=${duration} `
|
||||
}
|
||||
|
||||
_preCacheAudio(newTasks) {
|
||||
for (const task of newTasks) {
|
||||
/**
|
||||
* only precache audio for the a queued say if we have one or more non-Config verbs
|
||||
* ahead of it in the queue. This is because the Config verb returns immediately
|
||||
* and would not give us enough time to generate the audio. The point of precaching
|
||||
* is to take advantage of getting the audio in advance of being needed, so we need
|
||||
* to be confident we have some time before the say verb is executed, and the Config
|
||||
* does not give us that confidence since it returns immediately.
|
||||
*/
|
||||
const haveQueuedNonConfig = this.tasks.findIndex((t) => t.name !== TaskName.Config) !== -1;
|
||||
let tasks = haveQueuedNonConfig ? newTasks : [];
|
||||
if (!haveQueuedNonConfig) {
|
||||
const idxFirstNotConfig = newTasks.findIndex((t) => t.name !== TaskName.Config);
|
||||
if (-1 === idxFirstNotConfig) return;
|
||||
tasks = newTasks.slice(idxFirstNotConfig + 1);
|
||||
}
|
||||
|
||||
for (const task of tasks) {
|
||||
if (task.name === TaskName.Config && task.hasSynthesizer) {
|
||||
/* if they change synthesizer settings don't try to precache */
|
||||
break;
|
||||
@@ -1593,16 +1722,24 @@ Duration=${duration} `
|
||||
this._lccCallStatus(data);
|
||||
break;
|
||||
|
||||
case 'config':
|
||||
this._lccConfig(data, call_sid);
|
||||
break;
|
||||
|
||||
case 'dial':
|
||||
this._lccCallDial(data);
|
||||
break;
|
||||
|
||||
case 'dub':
|
||||
this._lccDub(data, call_sid);
|
||||
break;
|
||||
|
||||
case 'record':
|
||||
this.notifyRecordOptions(data);
|
||||
break;
|
||||
|
||||
case 'mute:status':
|
||||
this._lccMuteStatus(call_sid, data);
|
||||
this._lccMuteStatus(data, call_sid);
|
||||
break;
|
||||
|
||||
case 'conf:mute-status':
|
||||
@@ -1613,6 +1750,10 @@ Duration=${duration} `
|
||||
this._lccConfHoldStatus(data);
|
||||
break;
|
||||
|
||||
case 'conf:participant-action':
|
||||
this._lccConferenceParticipantAction(data);
|
||||
break;
|
||||
|
||||
case 'listen:status':
|
||||
this._lccListenStatus(data);
|
||||
break;
|
||||
@@ -1639,6 +1780,13 @@ Duration=${duration} `
|
||||
});
|
||||
break;
|
||||
|
||||
case 'boostAudioSignal':
|
||||
this._lccBoostAudioSignal(data, call_sid)
|
||||
.catch((err) => {
|
||||
this.logger.info({err, data}, 'CallSession:_onCommand - error boosting audio signal');
|
||||
});
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.info(`CallSession:_onCommand - invalid command ${command}`);
|
||||
}
|
||||
@@ -1892,6 +2040,10 @@ Duration=${duration} `
|
||||
}
|
||||
this.logger.debug(`CallSession:propagateAnswer - answered callSid ${this.callSid}`);
|
||||
}
|
||||
else {
|
||||
this.logger.debug('CallSession:propagateAnswer - call already answered - re-anchor media with a reinvite');
|
||||
await this.dlg.modify(this.ep.local.sdp);
|
||||
}
|
||||
}
|
||||
|
||||
async _onRequestWithinDialog(req, res) {
|
||||
|
||||
22
lib/tasks/answer.js
Normal file
22
lib/tasks/answer.js
Normal file
@@ -0,0 +1,22 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
|
||||
/**
|
||||
* Answer the call.
|
||||
* Note: This is rarely used, as the call is typically answered automatically when required by the app,
|
||||
* but it can be useful to force an answer before a pause in some cases
|
||||
*/
|
||||
class TaskAnswer extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Answer; }
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskAnswer;
|
||||
@@ -348,8 +348,14 @@ class Conference extends Task {
|
||||
Object.assign(opts, {flags: {
|
||||
...(this.endConferenceOnExit && {endconf: true}),
|
||||
...(this.startConferenceOnEnter && {moderator: true}),
|
||||
...(this.joinMuted && {joinMuted: true}),
|
||||
...((this.joinMuted || this.data.speakOnlyTo) && {joinMuted: true}),
|
||||
}});
|
||||
|
||||
/**
|
||||
* Note on the above: if we are joining in "coaching" mode (ie only going to heard by a subset of participants)
|
||||
* then we join muted temporarily, and then unmute ourselves once we have identified the subset of participants
|
||||
* to whom we will be speaking.
|
||||
*/
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -358,6 +364,11 @@ class Conference extends Task {
|
||||
this.memberId = memberId;
|
||||
this.confUuid = confUuid;
|
||||
|
||||
// set a tag for this member, if provided
|
||||
if (this.data.memberTag) {
|
||||
this.setMemberTag(this.data.memberTag);
|
||||
}
|
||||
|
||||
cs.setConferenceDetails(memberId, this.confName, confUuid);
|
||||
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
||||
if (response.body && /\d+/.test(response.body)) this.participantCount = parseInt(response.body);
|
||||
@@ -384,6 +395,9 @@ class Conference extends Task {
|
||||
.catch((err) => {});
|
||||
}
|
||||
|
||||
if (this.data.speakOnlyTo) {
|
||||
this.setCoachMode(this.data.speakOnlyTo);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Failed to join conference ${this.confName}`);
|
||||
throw err;
|
||||
@@ -428,7 +442,15 @@ class Conference extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
async doConferenceHold(cs, opts) {
|
||||
doConferenceMute(cs, opts) {
|
||||
assert (cs.isInConference);
|
||||
|
||||
const mute = opts.conf_mute_status === 'mute';
|
||||
this.ep.api(`conference ${this.confName} ${mute ? 'mute' : 'unmute'} ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error muting or unmuting participant'));
|
||||
}
|
||||
|
||||
doConferenceHold(cs, opts) {
|
||||
assert (cs.isInConference);
|
||||
|
||||
const {conf_hold_status, wait_hook} = opts;
|
||||
@@ -465,6 +487,40 @@ class Conference extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
async doConferenceParticipantAction(cs, opts) {
|
||||
const {action, tag} = opts;
|
||||
|
||||
switch (action) {
|
||||
case 'tag':
|
||||
await this.setMemberTag(tag);
|
||||
break;
|
||||
case 'untag':
|
||||
await this.clearMemberTag();
|
||||
break;
|
||||
case 'coach':
|
||||
await this.setCoachMode(tag);
|
||||
break;
|
||||
case 'uncoach':
|
||||
await this.clearCoachMode();
|
||||
break;
|
||||
case 'hold':
|
||||
this.doConferenceHold(cs, {conf_hold_status: 'hold'});
|
||||
break;
|
||||
case 'unhold':
|
||||
this.doConferenceHold(cs, {conf_hold_status: 'unhold'});
|
||||
break;
|
||||
case 'mute':
|
||||
this.doConferenceMute(cs, {conf_mute_status: 'mute'});
|
||||
break;
|
||||
case 'unmute':
|
||||
this.doConferenceMute(cs, {conf_mute_status: 'unmute'});
|
||||
break;
|
||||
default:
|
||||
this.logger.info(`Conference:doConferenceParticipantState - unhandled action ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
async _doWaitHookWhileOnHold(cs, dlg, wait_hook) {
|
||||
do {
|
||||
try {
|
||||
@@ -642,11 +698,14 @@ class Conference extends Task {
|
||||
}
|
||||
|
||||
// conference event handlers
|
||||
_onAddMember(logger, cs, evt) {
|
||||
logger.debug({evt}, `Conference:_onAddMember - member added to conference ${this.confName}`);
|
||||
}
|
||||
_onDelMember(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
this.participantCount = parseInt(evt.getHeader('Conference-Size'));
|
||||
if (memberId === this.memberId) {
|
||||
this.logger.info(`Conference:_onDelMember - I was dropped from conference ${this.confName}, task is complete`);
|
||||
logger.info(`Conference:_onDelMember - I was dropped from conference ${this.confName}, task is complete`);
|
||||
this.replaceEndpointAndEnd(cs);
|
||||
}
|
||||
}
|
||||
@@ -675,6 +734,53 @@ class Conference extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
async setCoachMode(speakOnlyTo) {
|
||||
try {
|
||||
const response = await this.ep.api('conference', [this.confName, 'gettag', speakOnlyTo, 'nomatch']);
|
||||
this.logger.info(`Conference:_setCoachMode: my audio will only be sent to particpants ${response}`);
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, response, 'nospeak']);
|
||||
this.speakOnlyTo = speakOnlyTo;
|
||||
this.coaching = response;
|
||||
} catch (err) {
|
||||
this.logger.error({err, speakOnlyTo}, '_setCoachMode: Error');
|
||||
}
|
||||
}
|
||||
|
||||
async clearCoachMode() {
|
||||
try {
|
||||
if (!this.coaching) {
|
||||
this.logger.info('Conference:_clearCoachMode: no coaching mode to clear');
|
||||
return;
|
||||
}
|
||||
this.logger.info(`Conference:_clearCoachMode: now sending my audio to all, including ${this.coaching}`);
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, this.coaching, 'clear']);
|
||||
this.speakOnlyTo = null;
|
||||
this.coaching = null;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, '_clearCoachMode: Error');
|
||||
}
|
||||
}
|
||||
|
||||
async setMemberTag(tag) {
|
||||
try {
|
||||
await this.ep.api('conference', [this.confName, 'tag', this.memberId, tag]);
|
||||
this.logger.info(`Conference:setMemberTag: set tag for ${this.memberId} to ${tag}`);
|
||||
this.memberTag = tag;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error setting tag for ${this.memberId} to ${tag}`);
|
||||
}
|
||||
}
|
||||
|
||||
async clearMemberTag() {
|
||||
try {
|
||||
await this.ep.api('conference', [this.confName, 'tag', this.memberId]);
|
||||
this.logger.info(`Conference:setMemberTag: clearing tag for ${this.memberId}`);
|
||||
this.memberTag = null;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error clearing tag for ${this.memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Conference;
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const parseDecibels = require('../utils/parse-decibels');
|
||||
|
||||
class TaskConfig extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
|
||||
[
|
||||
'synthesizer',
|
||||
'recognizer',
|
||||
@@ -11,7 +13,9 @@ class TaskConfig extends Task {
|
||||
'record',
|
||||
'listen',
|
||||
'transcribe',
|
||||
'actionHookDelayAction'
|
||||
'fillerNoise',
|
||||
'actionHookDelayAction',
|
||||
'boostAudioSignal'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
if ('notifyEvents' in this.data) {
|
||||
@@ -50,6 +54,7 @@ class TaskConfig extends Task {
|
||||
this.record?.action ||
|
||||
this.listen?.url ||
|
||||
this.data.amd ||
|
||||
'boostAudioSignal' in this.data ||
|
||||
this.transcribe?.enable) ?
|
||||
TaskPreconditions.Endpoint :
|
||||
TaskPreconditions.None;
|
||||
@@ -64,6 +69,8 @@ class TaskConfig extends Task {
|
||||
get hasRecording() { return Object.keys(this.record).length; }
|
||||
get hasListen() { return Object.keys(this.listen).length; }
|
||||
get hasTranscribe() { return Object.keys(this.transcribe).length; }
|
||||
get hasDub() { return Object.keys(this.dub).length; }
|
||||
get hasFillerNoise() { return Object.keys(this.fillerNoise).length; }
|
||||
|
||||
get summary() {
|
||||
const phrase = [];
|
||||
@@ -89,9 +96,11 @@ class TaskConfig extends Task {
|
||||
if (this.hasTranscribe) {
|
||||
phrase.push(this.transcribe.enable ? `transcribe ${this.transcribe.transcriptionHook}` : 'stop transcribe');
|
||||
}
|
||||
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.onHoldMusic) phrase.push(`onHoldMusic: ${this.onHoldMusic}`);
|
||||
if ('boostAudioSignal' in this.data) phrase.push(`setGain ${this.data.boostAudioSignal}`);
|
||||
return `${this.name}{${phrase.join(',')}}`;
|
||||
}
|
||||
|
||||
@@ -261,6 +270,24 @@ class TaskConfig extends Task {
|
||||
if (this.data.sipRequestWithinDialogHook) {
|
||||
cs.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
||||
}
|
||||
|
||||
if ('boostAudioSignal' in this.data) {
|
||||
const db = parseDecibels(this.data.boostAudioSignal);
|
||||
this.logger.info(`Config: boosting audio signal by ${db} dB`);
|
||||
const args = [ep.uuid, 'setGain', db];
|
||||
ep.api('uuid_dub', args).catch((err) => {
|
||||
this.logger.error(err, 'Error boosting audio signal');
|
||||
});
|
||||
}
|
||||
|
||||
if (this.hasFillerNoise) {
|
||||
const {enable, ...opts} = this.fillerNoise;
|
||||
this.logger.info({fillerNoise: this.fillerNoise}, 'Config: fillerNoise');
|
||||
if (!enable) cs.disableFillerNoise();
|
||||
else {
|
||||
cs.enableFillerNoise(opts);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
|
||||
@@ -14,6 +14,7 @@ const sessionTracker = require('../session/session-tracker');
|
||||
const DtmfCollector = require('../utils/dtmf-collector');
|
||||
const ConfirmCallSession = require('../session/confirm-call-session');
|
||||
const dbUtils = require('../utils/db-utils');
|
||||
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} = require('../config');
|
||||
@@ -101,6 +102,7 @@ class TaskDial extends Task {
|
||||
this.dtmfHook = this.data.dtmfHook;
|
||||
this.proxy = this.data.proxy;
|
||||
this.tag = this.data.tag;
|
||||
this.boostAudioSignal = this.data.boostAudioSignal;
|
||||
|
||||
if (this.dtmfHook) {
|
||||
const {parentDtmfCollector, childDtmfCollector} = parseDtmfOptions(logger, this.data.dtmfCapture || {});
|
||||
@@ -118,6 +120,9 @@ class TaskDial extends Task {
|
||||
if (this.data.transcribe) {
|
||||
this.transcribeTask = makeTask(logger, {'transcribe' : this.data.transcribe}, this);
|
||||
}
|
||||
if (this.data.dub && Array.isArray(this.data.dub) && this.data.dub.length > 0) {
|
||||
this.dubTasks = this.data.dub.map((d) => makeTask(logger, {'dub': d}, this));
|
||||
}
|
||||
|
||||
this.results = {};
|
||||
this.bridged = false;
|
||||
@@ -149,6 +154,7 @@ class TaskDial extends Task {
|
||||
this.cs.onHoldMusic ||
|
||||
ANCHOR_MEDIA_ALWAYS ||
|
||||
this.listenTask ||
|
||||
this.dubTasks ||
|
||||
this.transcribeTask ||
|
||||
this.startAmd;
|
||||
|
||||
@@ -551,9 +557,9 @@ class TaskDial extends Task {
|
||||
const str = this.callerId || req.callingNumber || '';
|
||||
const callingNumber = str.startsWith('+') ? str.substring(1) : str;
|
||||
const voip_carrier_sid = await lookupCarrierByPhoneNumber(cs.accountSid, callingNumber);
|
||||
this.logger.info(
|
||||
`Dial:_attemptCalls: selected ${voip_carrier_sid} for requested phone number: ${callingNumber}`);
|
||||
if (voip_carrier_sid) {
|
||||
this.logger.info(
|
||||
`Dial:_attemptCalls: selected voip_carrier_sid ${voip_carrier_sid} for callingNumber: ${callingNumber}`);
|
||||
opts.headers['X-Requested-Carrier-Sid'] = voip_carrier_sid;
|
||||
}
|
||||
}
|
||||
@@ -779,6 +785,17 @@ class TaskDial extends Task {
|
||||
dialCallSid: sd.callSid,
|
||||
});
|
||||
|
||||
if (this.dubTasks) {
|
||||
for (const dub of this.dubTasks) {
|
||||
try {
|
||||
await dub.exec(cs, {ep: sd.ep});
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error({err}, 'Dial:_selectSingleDial - error executing dubTask');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (this.parentDtmfCollector) this._installDtmfDetection(cs, cs.dlg);
|
||||
if (this.childDtmfCollector) this._installDtmfDetection(cs, this.dlg);
|
||||
if (cs.sipRequestWithinDialogHook) this._initSipIndialogRequestListener(cs, this.dlg);
|
||||
@@ -793,6 +810,18 @@ class TaskDial extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
/* boost audio signal if requested */
|
||||
if (this.boostAudioSignal) {
|
||||
try {
|
||||
const db = parseDecibels(this.boostAudioSignal);
|
||||
this.logger.info(`Dial: boosting audio signal by ${db} dB`);
|
||||
const args = [this.ep.uuid, 'setGain', db];
|
||||
await this.ep.api('uuid_dub', args);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_selectSingleDial - Error boosting audio signal');
|
||||
}
|
||||
}
|
||||
|
||||
/* if we can release the media back to the SBC, do so now */
|
||||
if (this.canReleaseMedia) setTimeout(this._releaseMedia.bind(this, cs, sd), 200);
|
||||
}
|
||||
|
||||
144
lib/tasks/dub.js
Normal file
144
lib/tasks/dub.js
Normal file
@@ -0,0 +1,144 @@
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const TtsTask = require('./tts-task');
|
||||
const assert = require('assert');
|
||||
const parseDecibels = require('../utils/parse-decibels');
|
||||
|
||||
/**
|
||||
* Dub task: add or remove additional audio tracks into the call
|
||||
*/
|
||||
class TaskDub extends TtsTask {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
|
||||
this.logger.debug({opts: this.data}, 'TaskDub constructor');
|
||||
['action', 'track', 'play', 'say', 'loop'].forEach((prop) => {
|
||||
this[prop] = this.data[prop];
|
||||
});
|
||||
this.gain = parseDecibels(this.data.gain);
|
||||
|
||||
assert.ok(this.action, 'TaskDub: action is required');
|
||||
assert.ok(this.track, 'TaskDub: track is required');
|
||||
}
|
||||
|
||||
get name() { return TaskName.Dub; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
super.exec(cs);
|
||||
|
||||
try {
|
||||
switch (this.action) {
|
||||
case 'addTrack':
|
||||
await this._addTrack(cs, ep);
|
||||
break;
|
||||
case 'removeTrack':
|
||||
await this._removeTrack(cs, ep);
|
||||
break;
|
||||
case 'silenceTrack':
|
||||
await this._silenceTrack(cs, ep);
|
||||
break;
|
||||
case 'playOnTrack':
|
||||
await this._playOnTrack(cs, ep);
|
||||
break;
|
||||
case 'sayOnTrack':
|
||||
await this._sayOnTrack(cs, ep);
|
||||
break;
|
||||
default:
|
||||
throw new Error(`TaskDub: unsupported action ${this.action}`);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'Error executing dub task');
|
||||
}
|
||||
}
|
||||
|
||||
async _addTrack(cs, ep) {
|
||||
this.logger.info(`adding track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'addTrack',
|
||||
track: this.track
|
||||
});
|
||||
|
||||
if (this.play) await this._playOnTrack(cs, ep);
|
||||
else if (this.say) await this._sayOnTrack(cs, ep);
|
||||
}
|
||||
|
||||
async _removeTrack(_cs, ep) {
|
||||
this.logger.info(`removing track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'removeTrack',
|
||||
track: this.track
|
||||
});
|
||||
}
|
||||
|
||||
async _silenceTrack(_cs, ep) {
|
||||
this.logger.info(`silencing track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'silenceTrack',
|
||||
track: this.track
|
||||
});
|
||||
}
|
||||
|
||||
async _playOnTrack(_cs, ep) {
|
||||
this.logger.info(`playing on track: ${this.track}`);
|
||||
await ep.dub({
|
||||
action: 'playOnTrack',
|
||||
track: this.track,
|
||||
play: this.play,
|
||||
loop: this.loop ? 'loop' : 'once',
|
||||
gain: this.gain
|
||||
});
|
||||
}
|
||||
|
||||
async _sayOnTrack(cs, ep) {
|
||||
const text = this.say.text || this.say;
|
||||
this.synthesizer = this.say.synthesizer || {};
|
||||
|
||||
if (Object.keys(this.synthesizer).length) {
|
||||
this.logger.info({synthesizer: this.synthesizer},
|
||||
`saying on track ${this.track}: ${text} with synthesizer options`);
|
||||
}
|
||||
else {
|
||||
this.logger.info(`saying on track ${this.track}: ${text}`);
|
||||
}
|
||||
this.synthesizer = this.synthesizer || {};
|
||||
|
||||
this.text = [text];
|
||||
|
||||
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
this.synthesizer.vendor :
|
||||
cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
this.synthesizer.voice :
|
||||
cs.speechSynthesisVoice;
|
||||
const label = this.synthesizer.label && this.synthesizer.label !== 'default' ?
|
||||
this.synthesizer.label :
|
||||
cs.speechSynthesisLabel;
|
||||
|
||||
const disableTtsStreaming = false;
|
||||
const filepath = await this._synthesizeWithSpecificVendor(cs, ep, {
|
||||
vendor, language, voice, label, disableTtsStreaming
|
||||
});
|
||||
assert.ok(filepath.length === 1, 'TaskDub: no filepath returned from synthesizer');
|
||||
|
||||
const path = filepath[0];
|
||||
if (!path.startsWith('say:{')) {
|
||||
/* we have a local file of mp3 or r8 of synthesized speech audio to play */
|
||||
this.logger.info(`playing synthesized speech from file on track ${this.track}: ${path}`);
|
||||
this.play = path;
|
||||
await this._playOnTrack(cs, ep);
|
||||
}
|
||||
else {
|
||||
this.logger.info(`doing actual text to speech file on track ${this.track}: ${path}`);
|
||||
await ep.dub({
|
||||
action: 'sayOnTrack',
|
||||
track: this.track,
|
||||
say: path,
|
||||
gain: this.gain
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskDub;
|
||||
@@ -338,6 +338,7 @@ class TaskEnqueue extends Task {
|
||||
this.logger.error({err}, `TaskEnqueue:_playHook error retrieving list info for queue ${this.queueName}`);
|
||||
}
|
||||
const json = await cs.application.requestor.request('verb:hook', hook, params, httpHeaders);
|
||||
this.logger.debug({json}, 'TaskEnqueue:_playHook: received response from waitHook');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
|
||||
const allowedTasks = tasks.filter((t) => allowed.includes(t.name));
|
||||
|
||||
@@ -27,7 +27,7 @@ class TaskGather extends SttTask {
|
||||
[
|
||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
|
||||
'speechTimeout', 'timeout', 'say', 'play', 'actionHookDelayAction'
|
||||
'speechTimeout', 'timeout', 'say', 'play', 'actionHookDelayAction', 'fillerNoise'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
// gather default input is digits
|
||||
@@ -91,6 +91,18 @@ class TaskGather extends SttTask {
|
||||
(this.playTask && this.playTask.earlyMedia);
|
||||
}
|
||||
|
||||
get hasFillerNoise() {
|
||||
return Object.keys(this.fillerNoise).length > 0 && this.fillerNoise.enabled !== false;
|
||||
}
|
||||
|
||||
get fillerNoiseUrl() {
|
||||
return this.fillerNoise.url;
|
||||
}
|
||||
|
||||
get fillerNoiseStartDelaySecs() {
|
||||
return this.fillerNoise.startDelaySecs;
|
||||
}
|
||||
|
||||
get summary() {
|
||||
let s = `${this.name}{`;
|
||||
if (this.input.length === 2) s += 'inputs=[speech,digits],';
|
||||
@@ -111,6 +123,11 @@ class TaskGather extends SttTask {
|
||||
await super.exec(cs, {ep});
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
|
||||
this.fillerNoise = {
|
||||
...(cs.fillerNoise || {}),
|
||||
...(this.fillerNoise || {})
|
||||
};
|
||||
|
||||
if (cs.hasGlobalSttHints && !this.maskGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
const setOfHints = new Set((this.data.recognizer.hints || [])
|
||||
@@ -255,6 +272,7 @@ class TaskGather extends SttTask {
|
||||
super.kill(cs);
|
||||
this._killAudio(cs);
|
||||
this._killActionHookDelayAction();
|
||||
this._clearFillerNoiseTimer();
|
||||
this.ep.removeAllListeners('dtmf');
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearAsrTimer();
|
||||
@@ -674,9 +692,29 @@ class TaskGather extends SttTask {
|
||||
this._finalAsrTimer = null;
|
||||
}
|
||||
|
||||
_startFillerNoiseTimer() {
|
||||
this._clearFillerNoiseTimer();
|
||||
this._fillerNoiseTimer = setTimeout(() => {
|
||||
this.logger.debug('Gather:_startFillerNoiseTimer - playing filler noise');
|
||||
this.ep?.play(this.fillerNoise.url);
|
||||
}, this.fillerNoise.startDelaySecs * 1000);
|
||||
}
|
||||
|
||||
_clearFillerNoiseTimer() {
|
||||
if (this._fillerNoiseTimer) clearTimeout(this._fillerNoiseTimer);
|
||||
this._fillerNoiseTimer = null;
|
||||
}
|
||||
|
||||
_killFillerNoise() {
|
||||
if (this._fillerNoiseTimer) {
|
||||
this.logger.debug('Gather:_killFillerNoise');
|
||||
this.ep?.api('uuid_break', this.ep.uuid);
|
||||
}
|
||||
}
|
||||
|
||||
_killAudio(cs) {
|
||||
if (!this.sayTask && !this.playTask && this.bargein) {
|
||||
if (this.ep?.connected && !this.playComplete) {
|
||||
if (this.hasFillerNoise || (!this.sayTask && !this.playTask && this.bargein)) {
|
||||
if (this.ep?.connected && (!this.playComplete || this.hasFillerNoise)) {
|
||||
this.logger.debug('Gather:_killAudio: killing playback of any audio');
|
||||
this.playComplete = true;
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
@@ -799,7 +837,7 @@ class TaskGather extends SttTask {
|
||||
if (this.bargein && (words + bufferedWords) < this.minBargeinWordCount) {
|
||||
this.logger.debug({evt, words, bufferedWords},
|
||||
'TaskGather:_onTranscription - final transcript but < min barge words');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
if (!emptyTranscript) this._bufferedTranscripts.push(evt);
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram'].includes(this.vendor)) this._startTranscribing(ep);
|
||||
return;
|
||||
}
|
||||
@@ -895,6 +933,10 @@ class TaskGather extends SttTask {
|
||||
this.logger.debug('TaskGather:_onTranscriptionComplete');
|
||||
}
|
||||
async _onJambonzError(cs, ep, evt) {
|
||||
if (this.vendor === 'google' && evt.error_code === 0) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError - ignoring google error code 0');
|
||||
return;
|
||||
}
|
||||
this.logger.info({evt}, 'TaskGather:_onJambonzError');
|
||||
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
|
||||
ep.stopTranscription({
|
||||
@@ -964,16 +1006,6 @@ class TaskGather extends SttTask {
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
this.logger.debug(`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');
|
||||
});
|
||||
}
|
||||
if (this.resolved) return;
|
||||
|
||||
this.resolved = true;
|
||||
@@ -991,6 +1023,13 @@ class TaskGather extends SttTask {
|
||||
'stt.resolve': reason,
|
||||
'stt.result': JSON.stringify(evt)
|
||||
});
|
||||
if (this.needsStt && this.ep && this.ep.connected) {
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
})
|
||||
.catch((err) => this.logger.error({err}, 'Error stopping transcription'));
|
||||
}
|
||||
|
||||
if (this.callSession && this.callSession.callGone) {
|
||||
this.logger.debug('TaskGather:_resolve - call is gone, not invoking web callback');
|
||||
@@ -1007,6 +1046,16 @@ class TaskGather extends SttTask {
|
||||
this._startActionHookNoResponseGiveUpTimer();
|
||||
}
|
||||
|
||||
if (this.hasFillerNoise && (reason.startsWith('dtmf') || reason.startsWith('speech'))) {
|
||||
if (this.fillerNoiseStartDelaySecs > 0) {
|
||||
this._startFillerNoiseTimer();
|
||||
}
|
||||
else {
|
||||
this.logger.debug(`TaskGather:_resolve - playing filler noise: ${this.fillerNoiseUrl}`);
|
||||
this.ep.play(this.fillerNoiseUrl);
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
if (reason.startsWith('dtmf')) {
|
||||
if (this.parentTask) this.parentTask.emit('dtmf', evt);
|
||||
@@ -1041,6 +1090,7 @@ class TaskGather extends SttTask {
|
||||
// Gather got response from hook, cancel all delay timers if there is any
|
||||
this._clearActionHookNoResponseTimer();
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
this._clearFillerNoiseTimer();
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
@@ -8,6 +8,10 @@ const DTMF_SPAN_NAME = 'dtmf';
|
||||
class TaskListen extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
/**
|
||||
* @deprecated
|
||||
* use bidirectionalAudio.enabled
|
||||
*/
|
||||
this.disableBidirectionalAudio = opts.disableBidirectionalAudio;
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
@@ -25,6 +29,15 @@ class TaskListen extends Task {
|
||||
this.results = {};
|
||||
this.playAudioQueue = [];
|
||||
this.isPlayingAudioFromQueue = false;
|
||||
this.bidirectionalAudio = {
|
||||
enabled: this.disableBidirectionalAudio === true ? false : true,
|
||||
...(this.data['bidirectionalAudio']),
|
||||
};
|
||||
|
||||
// From drachtio-version 3.0.40, forkAudioStart will send empty bugname, metadata together with
|
||||
// bidirectionalAudio params that cause old version of freeswitch missunderstand between bugname and
|
||||
// bidirectionalAudio params
|
||||
this._bugname = 'audio_fork';
|
||||
|
||||
if (this.transcribe) this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe}, this);
|
||||
}
|
||||
@@ -133,7 +146,8 @@ class TaskListen extends Task {
|
||||
mixType: this.mixType,
|
||||
sampling: this.sampleRate,
|
||||
...(this._bugname && {bugname: this._bugname}),
|
||||
metadata
|
||||
metadata,
|
||||
bidirectionalAudio: this.bidirectionalAudio || {}
|
||||
});
|
||||
this.recordStartTime = moment();
|
||||
if (this.maxLength) {
|
||||
@@ -153,7 +167,7 @@ class TaskListen extends Task {
|
||||
}
|
||||
|
||||
/* support bi-directional audio */
|
||||
if (!this.disableBidirectionalAudio) {
|
||||
if (this.bidirectionalAudio.enabled) {
|
||||
ep.addCustomEventListener(ListenEvents.PlayAudio, this._onPlayAudio.bind(this, ep));
|
||||
}
|
||||
ep.addCustomEventListener(ListenEvents.KillAudio, this._onKillAudio.bind(this, ep));
|
||||
|
||||
@@ -14,6 +14,9 @@ function makeTask(logger, obj, parent) {
|
||||
}
|
||||
validateVerb(name, data, logger);
|
||||
switch (name) {
|
||||
case TaskName.Answer:
|
||||
const TaskAnswer = require('./answer');
|
||||
return new TaskAnswer(logger, data, parent);
|
||||
case TaskName.SipDecline:
|
||||
const TaskSipDecline = require('./sip_decline');
|
||||
return new TaskSipDecline(logger, data, parent);
|
||||
@@ -41,6 +44,9 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.Dtmf:
|
||||
const TaskDtmf = require('./dtmf');
|
||||
return new TaskDtmf(logger, data, parent);
|
||||
case TaskName.Dub:
|
||||
const TaskDub = require('./dub');
|
||||
return new TaskDub(logger, data, parent);
|
||||
case TaskName.Enqueue:
|
||||
const TaskEnqueue = require('./enqueue');
|
||||
return new TaskEnqueue(logger, data, parent);
|
||||
|
||||
188
lib/tasks/say.js
188
lib/tasks/say.js
@@ -1,4 +1,4 @@
|
||||
const Task = require('./task');
|
||||
const TtsTask = require('./tts-task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const pollySSMLSplit = require('polly-ssml-split');
|
||||
|
||||
@@ -29,9 +29,9 @@ const parseTextFromSayString = (text) => {
|
||||
return text.slice(closingBraceIndex + 1);
|
||||
};
|
||||
|
||||
class TaskSay extends Task {
|
||||
class TaskSay extends TtsTask {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts);
|
||||
super(logger, opts, parentTask);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.text = (Array.isArray(this.data.text) ? this.data.text : [this.data.text])
|
||||
@@ -39,10 +39,6 @@ class TaskSay extends Task {
|
||||
.flat();
|
||||
|
||||
this.loop = this.data.loop || 1;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
this.isHandledByPrimaryProvider = true;
|
||||
}
|
||||
|
||||
@@ -56,156 +52,6 @@ class TaskSay extends Task {
|
||||
return `${this.name}{${this.text[0]}}`;
|
||||
}
|
||||
|
||||
_validateURL(urlString) {
|
||||
try {
|
||||
new URL(urlString);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async _synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label, preCache = false}) {
|
||||
const {srf, accountSid:account_sid} = cs;
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
|
||||
const {writeAlerts, AlertType, stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
const engine = this.synthesizer.engine || 'standard';
|
||||
const salt = cs.callSid;
|
||||
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'tts', label);
|
||||
/* parse Nuance voices into name and model */
|
||||
let model;
|
||||
if (vendor === 'nuance' && voice) {
|
||||
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
|
||||
if (arr) {
|
||||
voice = arr[1];
|
||||
model = arr[2];
|
||||
}
|
||||
} else if (vendor === 'deepgram') {
|
||||
model = voice;
|
||||
}
|
||||
|
||||
/* allow for microsoft custom region voice and api_key to be specified as an override */
|
||||
if (vendor === 'microsoft' && this.options.deploymentId) {
|
||||
credentials = credentials || {};
|
||||
credentials.use_custom_tts = true;
|
||||
credentials.custom_tts_endpoint = this.options.deploymentId;
|
||||
credentials.api_key = this.options.apiKey || credentials.apiKey;
|
||||
credentials.region = this.options.region || credentials.region;
|
||||
voice = this.options.voice || voice;
|
||||
} else if (vendor === 'elevenlabs') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
credentials.voice_settings = this.options.voice_settings || {};
|
||||
credentials.optimize_streaming_latency = this.options.optimize_streaming_latency
|
||||
|| credentials.optimize_streaming_latency;
|
||||
voice = this.options.voice_id || voice;
|
||||
}
|
||||
|
||||
ep.set({
|
||||
tts_engine: vendor,
|
||||
tts_voice: voice,
|
||||
cache_speech_handles: 1,
|
||||
}).catch((err) => this.logger.info({err}, 'Error setting tts_engine on endpoint'));
|
||||
|
||||
if (!preCache) this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
this.notifyError({
|
||||
msg: 'TTS error',
|
||||
details:`No speech credentials provisioned for selected vendor ${vendor}`
|
||||
});
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
// synthesize all of the text elements
|
||||
let lastUpdated = false;
|
||||
|
||||
/* produce an audio segment from the provided text */
|
||||
const generateAudio = async(text) => {
|
||||
if (this.killed) return;
|
||||
if (text.startsWith('silence_stream://')) return text;
|
||||
|
||||
/* otel: trace time for tts */
|
||||
if (!preCache) {
|
||||
const {span} = this.startChildSpan('tts-generation', {
|
||||
'tts.vendor': vendor,
|
||||
'tts.language': language,
|
||||
'tts.voice': voice
|
||||
});
|
||||
this.otelSpan = span;
|
||||
}
|
||||
try {
|
||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
||||
account_sid,
|
||||
text,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
model,
|
||||
salt,
|
||||
credentials,
|
||||
options: this.options,
|
||||
disableTtsCache : this.disableTtsCache,
|
||||
preCache
|
||||
});
|
||||
if (!filePath.startsWith('say:')) {
|
||||
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (this.otelSpan) {
|
||||
this.otelSpan.setAttributes({'tts.cached': servedFromCache});
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
}
|
||||
if (!servedFromCache && !lastUpdated) {
|
||||
lastUpdated = true;
|
||||
updateSpeechCredentialLastUsed(credentials.speech_credential_sid).catch(() => {/* logged error */});
|
||||
}
|
||||
if (!servedFromCache && rtt && !preCache) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
language,
|
||||
characters: text.length,
|
||||
elapsedTime: rtt
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug('a streaming tts api will be used');
|
||||
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
|
||||
return modifiedPath;
|
||||
}
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error synthesizing tts');
|
||||
if (this.otelSpan) this.otelSpan.end();
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: err.message
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
this.notifyError({msg: 'TTS error', details: err.message || err});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const arr = this.text.map((t) => (this._validateURL(t) ? t : generateAudio(t)));
|
||||
return (await Promise.all(arr)).filter((fp) => fp && fp.length);
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
const {srf, accountSid:account_sid} = cs;
|
||||
const {writeAlerts, AlertType} = srf.locals;
|
||||
@@ -341,6 +187,9 @@ class TaskSay extends Task {
|
||||
if (key.startsWith('variable_tts_')) {
|
||||
let newKey = key.substring('variable_tts_'.length)
|
||||
.replace('whisper_', 'whisper.')
|
||||
.replace('deepgram_', 'deepgram.')
|
||||
.replace('playht_', 'playht.')
|
||||
.replace('rimelabs_', 'rimelabs.')
|
||||
.replace('elevenlabs_', 'elevenlabs.');
|
||||
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
||||
attrs[newKey] = value;
|
||||
@@ -352,6 +201,9 @@ class TaskSay extends Task {
|
||||
}
|
||||
|
||||
const spanMapping = {
|
||||
// IMPORTANT!!! JAMBONZ WEBAPP WILL SHOW TEXT PERFECTLY IF THE SPAN NAME IS SMALLER OR EQUAL 25 CHARACTERS.
|
||||
// EX: whisper.ratelim_reqs has length 20 <= 25 which is perfect
|
||||
// Elevenlabs
|
||||
'elevenlabs.reported_latency_ms': 'elevenlabs.latency_ms',
|
||||
'elevenlabs.request_id': 'elevenlabs.req_id',
|
||||
'elevenlabs.history_item_id': 'elevenlabs.item_id',
|
||||
@@ -359,11 +211,33 @@ const spanMapping = {
|
||||
'elevenlabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'elevenlabs.connect_time_ms': 'connect_ms',
|
||||
'elevenlabs.final_response_time_ms': 'final_response_ms',
|
||||
// Whisper
|
||||
'whisper.reported_latency_ms': 'whisper.latency_ms',
|
||||
'whisper.request_id': 'whisper.req_id',
|
||||
'whisper.reported_organization': 'whisper.organization',
|
||||
'whisper.reported_ratelimit_requests': 'whisper.ratelimit',
|
||||
'whisper.reported_ratelimit_remaining_requests': 'whisper.ratelimit_remain',
|
||||
'whisper.reported_ratelimit_reset_requests': 'whisper.ratelimit_reset',
|
||||
'whisper.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'whisper.connect_time_ms': 'connect_ms',
|
||||
'whisper.final_response_time_ms': 'final_response_ms',
|
||||
// Deepgram
|
||||
'deepgram.request_id': 'deepgram.req_id',
|
||||
'deepgram.reported_model_name': 'deepgram.model_name',
|
||||
'deepgram.reported_model_uuid': 'deepgram.model_uuid',
|
||||
'deepgram.reported_char_count': 'deepgram.char_count',
|
||||
'deepgram.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'deepgram.connect_time_ms': 'connect_ms',
|
||||
'deepgram.final_response_time_ms': 'final_response_ms',
|
||||
// Playht
|
||||
'playht.request_id': 'playht.req_id',
|
||||
'playht.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'playht.connect_time_ms': 'connect_ms',
|
||||
'playht.final_response_time_ms': 'final_response_ms',
|
||||
// Rimelabs
|
||||
'rimelabs.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'rimelabs.connect_time_ms': 'connect_ms',
|
||||
'rimelabs.final_response_time_ms': 'final_response_ms',
|
||||
};
|
||||
|
||||
module.exports = TaskSay;
|
||||
|
||||
@@ -56,24 +56,20 @@ class SttTask extends Task {
|
||||
super.exec(cs);
|
||||
this.ep = ep;
|
||||
this.ep2 = ep2;
|
||||
// copy all value from config verb to this object.
|
||||
|
||||
// use session preferences if we don't have specific verb-level settings.
|
||||
if (cs.recognizer) {
|
||||
for (const k in cs.recognizer) {
|
||||
if (Array.isArray(this.data.recognizer[k]) ||
|
||||
Array.isArray(cs.recognizer[k])) {
|
||||
this.data.recognizer[k] = [
|
||||
...this.data.recognizer[k],
|
||||
...cs.recognizer[k]
|
||||
];
|
||||
} else if (typeof this.data.recognizer[k] === 'object' ||
|
||||
typeof cs.recognizer[k] === 'object'
|
||||
) {
|
||||
this.data.recognizer[k] = {
|
||||
...this.data.recognizer[k],
|
||||
...cs.recognizer[k]
|
||||
};
|
||||
const newValue = this.data.recognizer && this.data.recognizer[k] !== undefined ?
|
||||
this.data.recognizer[k] :
|
||||
cs.recognizer[k];
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
this.data.recognizer[k] = [...(this.data.recognizer[k] || []), ...cs.recognizer[k]];
|
||||
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||
this.data.recognizer[k] = { ...(this.data.recognizer[k] || {}), ...cs.recognizer[k] };
|
||||
} else {
|
||||
this.data.recognizer[k] = cs.recognizer[k] || this.data.recognizer[k];
|
||||
this.data.recognizer[k] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,22 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
/* for nested transcribe in dial, unless the app explicitly says so we want to transcribe both legs */
|
||||
if (this.parentTask?.name === TaskName.Dial && this.separateRecognitionPerChannel !== false) {
|
||||
this.separateRecognitionPerChannel = true;
|
||||
if (this.parentTask?.name === TaskName.Dial) {
|
||||
if (this.data.channel === 1 || this.data.channel === 2) {
|
||||
/* transcribe only the channel specified */
|
||||
this.separateRecognitionPerChannel = false;
|
||||
this.channel = this.data.channel;
|
||||
logger.debug(`TaskTranscribe: transcribing only channel ${this.channel} in the Dial verb`);
|
||||
}
|
||||
else if (this.separateRecognitionPerChannel !== false) {
|
||||
this.separateRecognitionPerChannel = true;
|
||||
}
|
||||
else {
|
||||
this.channel = 1;
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.channel = 1;
|
||||
}
|
||||
|
||||
this.childSpan = [null, null];
|
||||
@@ -44,13 +58,21 @@ class TaskTranscribe extends SttTask {
|
||||
this.isContinuousAsr = true;
|
||||
}
|
||||
/* buffer speech for continuous asr */
|
||||
this._bufferedTranscripts = [];
|
||||
this._bufferedTranscripts = [ [], [] ]; // for channel 1 and 2
|
||||
this.bugname_prefix = 'transcribe_';
|
||||
this.paused = false;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Transcribe; }
|
||||
|
||||
get transcribing1() {
|
||||
return this.channel === 1 || this.separateRecognitionPerChannel;
|
||||
}
|
||||
|
||||
get transcribing2() {
|
||||
return this.channel === 2 || this.separateRecognitionPerChannel && this.ep2;
|
||||
}
|
||||
|
||||
async exec(cs, {ep, ep2}) {
|
||||
await super.exec(cs, {ep, ep2});
|
||||
|
||||
@@ -73,8 +95,10 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
try {
|
||||
await this._startTranscribing(cs, ep, 1);
|
||||
if (this.separateRecognitionPerChannel && ep2) {
|
||||
if (this.transcribing1) {
|
||||
await this._startTranscribing(cs, ep, 1);
|
||||
}
|
||||
if (this.transcribing2) {
|
||||
await this._startTranscribing(cs, ep2, 2);
|
||||
}
|
||||
|
||||
@@ -91,7 +115,7 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
async _stopTranscription() {
|
||||
let stopTranscription = false;
|
||||
if (this.ep?.connected) {
|
||||
if (this.transcribing1 && this.ep?.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
@@ -99,7 +123,7 @@ class TaskTranscribe extends SttTask {
|
||||
})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
if (this.separateRecognitionPerChannel && this.ep2 && this.ep2.connected) {
|
||||
if (this.transcribing2 && this.ep2.connected) {
|
||||
stopTranscription = true;
|
||||
this.ep2.stopTranscription({vendor: this.vendor, bugname: this.bugname})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
@@ -128,10 +152,8 @@ class TaskTranscribe extends SttTask {
|
||||
break;
|
||||
case TranscribeStatus.Resume:
|
||||
this.paused = false;
|
||||
await this._startTranscribing(this.cs, this.ep, 1);
|
||||
if (this.separateRecognitionPerChannel && this.ep2) {
|
||||
await this._startTranscribing(this.cs, this.ep2, 2);
|
||||
}
|
||||
if (this.transcribing1) await this._startTranscribing(this.cs, this.ep, 1);
|
||||
if (this.transcribing2) await this._startTranscribing(this.cs, this.ep2, 2);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -294,7 +316,7 @@ class TaskTranscribe extends SttTask {
|
||||
vendor: this.vendor,
|
||||
interim: this.interim ? true : false,
|
||||
locale: this.language,
|
||||
channels: /*this.separateRecognitionPerChannel ? 2 : */ 1,
|
||||
channels: 1,
|
||||
bugname: this.bugname,
|
||||
hostport: this.hostport
|
||||
});
|
||||
@@ -304,6 +326,7 @@ class TaskTranscribe extends SttTask {
|
||||
// make sure this is not a transcript from answering machine detection
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
const bufferedTranscripts = this._bufferedTranscripts[channel - 1];
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
if (this.paused) {
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - paused, ignoring transcript');
|
||||
@@ -313,14 +336,14 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
if (this._bufferedTranscripts.length === 0) {
|
||||
if (bufferedTranscripts.length === 0) {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
||||
}
|
||||
else {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, return buffered transcript');
|
||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, 1, this.language, this.vendor);
|
||||
evt = this.consolidateTranscripts(bufferedTranscripts, channel, this.language, this.vendor);
|
||||
evt.is_final = true;
|
||||
this._bufferedTranscripts = [];
|
||||
this._bufferedTranscripts[channel - 1] = [];
|
||||
this._resolve(channel, evt);
|
||||
}
|
||||
return;
|
||||
@@ -337,11 +360,11 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
let emptyTranscript = false;
|
||||
if (evt.is_final) {
|
||||
if (evt.alternatives[0].transcript === '' && !cs.callGone && !this.killed) {
|
||||
if (evt.alternatives.length === 0 || evt.alternatives[0].transcript === '' && !cs.callGone && !this.killed) {
|
||||
emptyTranscript = true;
|
||||
if (finished === 'true' &&
|
||||
['microsoft', 'deepgram'].includes(this.vendor) &&
|
||||
this._bufferedTranscripts.length === 0) {
|
||||
bufferedTranscripts.length === 0) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - got empty transcript from old gather, disregarding');
|
||||
return;
|
||||
}
|
||||
@@ -354,7 +377,7 @@ class TaskTranscribe extends SttTask {
|
||||
'TaskGather:_onTranscription - got empty deepgram transcript during continous asr, continue listening');
|
||||
return;
|
||||
}
|
||||
else if (this.vendor === 'deepgram' && this._bufferedTranscripts.length > 0) {
|
||||
else if (this.vendor === 'deepgram' && bufferedTranscripts.length > 0) {
|
||||
this.logger.info({evt},
|
||||
'TaskGather:_onTranscription - got empty transcript from deepgram, return the buffered transcripts');
|
||||
}
|
||||
@@ -370,7 +393,7 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
}
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got transcript during continous asr');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
bufferedTranscripts.push(evt);
|
||||
this._startAsrTimer(channel);
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
@@ -386,12 +409,12 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
else if (this.vendor === 'deepgram') {
|
||||
/* compile transcripts into one */
|
||||
if (!emptyTranscript) this._bufferedTranscripts.push(evt);
|
||||
if (!emptyTranscript) bufferedTranscripts.push(evt);
|
||||
|
||||
/* deepgram can send an empty and final transcript; only if we have any buffered should we resolve */
|
||||
if (this._bufferedTranscripts.length === 0) return;
|
||||
evt = this.consolidateTranscripts(this._bufferedTranscripts, channel, this.language);
|
||||
this._bufferedTranscripts = [];
|
||||
if (bufferedTranscripts.length === 0) return;
|
||||
evt = this.consolidateTranscripts(bufferedTranscripts, channel, this.language);
|
||||
this._bufferedTranscripts[channel - 1] = [];
|
||||
}
|
||||
|
||||
/* here is where we return a final transcript */
|
||||
@@ -410,7 +433,7 @@ class TaskTranscribe extends SttTask {
|
||||
const originalEvent = evt.vendor.evt;
|
||||
if (originalEvent.is_final && evt.alternatives[0].transcript !== '') {
|
||||
this.logger.debug({evt}, 'Gather:_onTranscription - buffering a completed (partial) deepgram transcript');
|
||||
this._bufferedTranscripts.push(evt);
|
||||
bufferedTranscripts.push(evt);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -512,6 +535,10 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
async _onJambonzError(cs, _ep, evt) {
|
||||
if (this.vendor === 'google' && evt.error_code === 0) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError - ignoring google error code 0');
|
||||
return;
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
if (this.paused) return;
|
||||
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
|
||||
@@ -538,6 +565,7 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (this.vendor === 'nuance') {
|
||||
const {code, error} = evt;
|
||||
//TODO: fix below, currently _resolve does not send timeout events
|
||||
if (code === 404 && error === 'No speech') return this._resolve('timeout');
|
||||
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
|
||||
}
|
||||
@@ -570,8 +598,9 @@ class TaskTranscribe extends SttTask {
|
||||
this._clearAsrTimer(channel);
|
||||
this._asrTimer = setTimeout(() => {
|
||||
this.logger.debug(`TaskTranscribe:_startAsrTimer - asr timer went off for channel: ${channel}`);
|
||||
const evt = this.consolidateTranscripts(this._bufferedTranscripts, channel, this.language, this.vendor);
|
||||
this._bufferedTranscripts = [];
|
||||
const evt = this.consolidateTranscripts(
|
||||
this._bufferedTranscripts[channel - 1], channel, this.language, this.vendor);
|
||||
this._bufferedTranscripts[channel - 1] = [];
|
||||
this._resolve(channel, evt);
|
||||
}, this.asrTimeout);
|
||||
this.logger.debug(`TaskTranscribe:_startAsrTimer: set for ${this.asrTimeout}ms for channel ${channel}`);
|
||||
|
||||
183
lib/tasks/tts-task.js
Normal file
183
lib/tasks/tts-task.js
Normal file
@@ -0,0 +1,183 @@
|
||||
const Task = require('./task');
|
||||
const { TaskPreconditions } = require('../utils/constants');
|
||||
|
||||
class TtsTask extends Task {
|
||||
|
||||
constructor(logger, data, parentTask) {
|
||||
super(logger, data);
|
||||
this.parentTask = parentTask;
|
||||
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
}
|
||||
|
||||
async _synthesizeWithSpecificVendor(cs, ep, {
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
label,
|
||||
disableTtsStreaming,
|
||||
preCache
|
||||
}) {
|
||||
const {srf, accountSid:account_sid} = cs;
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, srf);
|
||||
const {writeAlerts, AlertType, stats} = srf.locals;
|
||||
const {synthAudio} = srf.locals.dbHelpers;
|
||||
const engine = this.synthesizer.engine || 'standard';
|
||||
const salt = cs.callSid;
|
||||
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'tts', label);
|
||||
/* parse Nuance voices into name and model */
|
||||
let model;
|
||||
if (vendor === 'nuance' && voice) {
|
||||
const arr = /([A-Za-z-]*)\s+-\s+(enhanced|standard)/.exec(voice);
|
||||
if (arr) {
|
||||
voice = arr[1];
|
||||
model = arr[2];
|
||||
}
|
||||
} else if (vendor === 'deepgram') {
|
||||
model = voice;
|
||||
}
|
||||
|
||||
/* allow for microsoft custom region voice and api_key to be specified as an override */
|
||||
if (vendor === 'microsoft' && this.options.deploymentId) {
|
||||
credentials = credentials || {};
|
||||
credentials.use_custom_tts = true;
|
||||
credentials.custom_tts_endpoint = this.options.deploymentId;
|
||||
credentials.api_key = this.options.apiKey || credentials.apiKey;
|
||||
credentials.region = this.options.region || credentials.region;
|
||||
voice = this.options.voice || voice;
|
||||
} else if (vendor === 'elevenlabs') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
credentials.voice_settings = this.options.voice_settings || {};
|
||||
credentials.optimize_streaming_latency = this.options.optimize_streaming_latency
|
||||
|| credentials.optimize_streaming_latency;
|
||||
voice = this.options.voice_id || voice;
|
||||
}
|
||||
|
||||
ep.set({
|
||||
tts_engine: vendor,
|
||||
tts_voice: voice,
|
||||
cache_speech_handles: 1,
|
||||
...(vendor === 'google' && {
|
||||
GOOGLE_TTS_APPLICATION_CREDENTIALS: JSON.stringify(credentials.credentials)
|
||||
})
|
||||
}).catch((err) => this.logger.info({err}, `${this.name}: Error setting tts_engine on endpoint`));
|
||||
|
||||
if (!preCache) this.logger.info({vendor, language, voice, model}, `${this.name}:exec`);
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
this.notifyError({
|
||||
msg: 'TTS error',
|
||||
details:`No speech credentials provisioned for selected vendor ${vendor}`
|
||||
});
|
||||
throw new Error('no provisioned speech credentials for TTS');
|
||||
}
|
||||
// synthesize all of the text elements
|
||||
let lastUpdated = false;
|
||||
|
||||
/* produce an audio segment from the provided text */
|
||||
const generateAudio = async(text) => {
|
||||
if (this.killed) return;
|
||||
if (text.startsWith('silence_stream://')) return text;
|
||||
|
||||
/* otel: trace time for tts */
|
||||
if (!preCache && !this.parentTask) {
|
||||
const {span} = this.startChildSpan('tts-generation', {
|
||||
'tts.vendor': vendor,
|
||||
'tts.language': language,
|
||||
'tts.voice': voice
|
||||
});
|
||||
this.otelSpan = span;
|
||||
}
|
||||
try {
|
||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
||||
account_sid,
|
||||
text,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
engine,
|
||||
model,
|
||||
salt,
|
||||
credentials,
|
||||
options: this.options,
|
||||
disableTtsCache : this.disableTtsCache,
|
||||
disableTtsStreaming,
|
||||
preCache
|
||||
});
|
||||
if (!filePath.startsWith('say:')) {
|
||||
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (this.otelSpan) {
|
||||
this.otelSpan.setAttributes({'tts.cached': servedFromCache});
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
}
|
||||
if (!servedFromCache && !lastUpdated) {
|
||||
lastUpdated = true;
|
||||
updateSpeechCredentialLastUsed(credentials.speech_credential_sid).catch(() => {/* logged error */});
|
||||
}
|
||||
if (!servedFromCache && rtt && !preCache) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
language,
|
||||
characters: text.length,
|
||||
elapsedTime: rtt
|
||||
});
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug('a streaming tts api will be used');
|
||||
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
|
||||
return modifiedPath;
|
||||
}
|
||||
return filePath;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error synthesizing tts');
|
||||
if (this.otelSpan) this.otelSpan.end();
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: err.message
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
this.notifyError({msg: 'TTS error', details: err.message || err});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
|
||||
const arr = this.text.map((t) => (this._validateURL(t) ? t : generateAudio(t)));
|
||||
return (await Promise.all(arr)).filter((fp) => fp && fp.length);
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
_validateURL(urlString) {
|
||||
try {
|
||||
new URL(urlString);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TtsTask;
|
||||
@@ -26,25 +26,25 @@ class BackgroundTaskManager extends Emitter {
|
||||
return this.tasks.size;
|
||||
}
|
||||
|
||||
async newTask(type, taskOpts) {
|
||||
this.logger.info({taskOpts}, `initiating Background task ${type}`);
|
||||
async newTask(type, opts, sticky = false) {
|
||||
this.logger.info({opts}, `initiating Background task ${type}`);
|
||||
if (this.tasks.has(type)) {
|
||||
this.logger.info(`Background task ${type} is running, skiped`);
|
||||
this.logger.info(`Background task ${type} is running, skipped`);
|
||||
return;
|
||||
}
|
||||
let task;
|
||||
switch (type) {
|
||||
case 'listen':
|
||||
task = await this._initListen(taskOpts);
|
||||
task = await this._initListen(opts);
|
||||
break;
|
||||
case 'bargeIn':
|
||||
task = await this._initBargeIn(taskOpts);
|
||||
task = await this._initBargeIn(opts);
|
||||
break;
|
||||
case 'record':
|
||||
task = await this._initRecord();
|
||||
break;
|
||||
case 'transcribe':
|
||||
task = await this._initTranscribe(taskOpts);
|
||||
task = await this._initTranscribe(opts);
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
@@ -52,6 +52,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
if (task) {
|
||||
this.tasks.set(type, task);
|
||||
}
|
||||
if (task && sticky) task.sticky = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -64,8 +65,6 @@ class BackgroundTaskManager extends Emitter {
|
||||
task.kill();
|
||||
// Remove task from managed List
|
||||
this.tasks.delete(type);
|
||||
} else {
|
||||
this.logger.debug(`stopping background task, ${type} is not running, skipped`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -118,7 +117,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
this._taskCompleted('bargeIn', task);
|
||||
if (task.sticky && !this.cs.callGone && !this.cs._stopping) {
|
||||
this.logger.info('BackgroundTaskManager:_initBargeIn: restarting background bargeIn');
|
||||
this.newTask('bargeIn', opts);
|
||||
this.newTask('bargeIn', opts, true);
|
||||
}
|
||||
return;
|
||||
})
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
{
|
||||
"TaskName": {
|
||||
"Cognigy": "cognigy",
|
||||
"Answer": "answer",
|
||||
"Conference": "conference",
|
||||
"Config": "config",
|
||||
"Dequeue": "dequeue",
|
||||
"Dial": "dial",
|
||||
"Dialogflow": "dialogflow",
|
||||
"Dtmf": "dtmf",
|
||||
"Dub": "dub",
|
||||
"Enqueue": "enqueue",
|
||||
"Gather": "gather",
|
||||
"Hangup": "hangup",
|
||||
@@ -29,7 +30,7 @@
|
||||
"Tag": "tag",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"AllowedSipRecVerbs": ["config", "gather", "transcribe", "listen", "tag"],
|
||||
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag"],
|
||||
"AllowedConfirmSessionVerbs": ["config", "gather", "plays", "say", "tag"],
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
@@ -170,6 +171,7 @@
|
||||
"session:new",
|
||||
"session:reconnect",
|
||||
"session:redirect",
|
||||
"session:adulting",
|
||||
"call:status",
|
||||
"queue:status",
|
||||
"dial:confirm",
|
||||
|
||||
@@ -94,6 +94,17 @@ const speechMapper = (cred) => {
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
obj.options = o.options;
|
||||
} else if ('playht' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.user_id = o.user_id;
|
||||
obj.voice_engine = o.voice_engine;
|
||||
obj.options = o.options;
|
||||
} else if ('rimelabs' === 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;
|
||||
|
||||
18
lib/utils/parse-decibels.js
Normal file
18
lib/utils/parse-decibels.js
Normal file
@@ -0,0 +1,18 @@
|
||||
const parseDecibels = (db) => {
|
||||
if (!db) return 0;
|
||||
if (typeof db === 'number') {
|
||||
return db;
|
||||
}
|
||||
else if (typeof db === 'string') {
|
||||
const match = db.match(/([+-]?\d+(\.\d+)?)\s*db/i);
|
||||
if (match) {
|
||||
return Math.trunc(parseFloat(match[1]));
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
} else {
|
||||
return 0;
|
||||
}
|
||||
};
|
||||
|
||||
module.exports = parseDecibels;
|
||||
@@ -295,17 +295,17 @@ class SingleDialer extends Emitter {
|
||||
if (err.status === 487) status.callStatus = CallStatus.NoAnswer;
|
||||
else if ([486, 600].includes(err.status)) status.callStatus = CallStatus.Busy;
|
||||
this.logger.info(`SingleDialer:exec outdial failure ${err.status}`);
|
||||
inviteSpan.setAttributes({'invite.status_code': err.status});
|
||||
inviteSpan.end();
|
||||
inviteSpan?.setAttributes({'invite.status_code': err.status});
|
||||
inviteSpan?.end();
|
||||
}
|
||||
else {
|
||||
this.logger.error(err, 'SingleDialer:exec');
|
||||
status.sipStatus = 500;
|
||||
inviteSpan.setAttributes({
|
||||
inviteSpan?.setAttributes({
|
||||
'invite.status_code': 500,
|
||||
'invite.err': err.message
|
||||
});
|
||||
inviteSpan.end();
|
||||
inviteSpan?.end();
|
||||
}
|
||||
this.emit('callStatusChange', status);
|
||||
if (this.ep) this.ep.destroy();
|
||||
@@ -413,6 +413,7 @@ class SingleDialer extends Emitter {
|
||||
const app = {...application};
|
||||
if ('WS' === app.call_hook?.method ||
|
||||
app.call_hook?.url.startsWith('ws://') || app.call_hook?.url.startsWith('wss://')) {
|
||||
if (app.call_hook?.url) app.call_hook.url += '/adulting';
|
||||
const requestor = new WsRequestor(logger, this.accountInfo.account.account_sid,
|
||||
app.call_hook, this.accountInfo.account.webhook_secret);
|
||||
app.requestor = requestor;
|
||||
@@ -438,6 +439,13 @@ class SingleDialer extends Emitter {
|
||||
tasks,
|
||||
rootSpan
|
||||
});
|
||||
app.requestor.request('session:adulting', '/adulting', {
|
||||
...cs.callInfo.toJSON(),
|
||||
parentCallInfo: this.parentCallInfo
|
||||
}).catch((err) => {
|
||||
newLogger.error({err}, 'doAdulting: error sending adulting request');
|
||||
});
|
||||
|
||||
cs.req = this.req;
|
||||
cs.exec().catch((err) => newLogger.error({err}, 'doAdulting: error executing session'));
|
||||
return cs;
|
||||
|
||||
@@ -270,7 +270,7 @@ const normalizeDeepgram = (evt, channel, language, shortUtterance) => {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: shortUtterance ? evt.is_final : evt.speech_final,
|
||||
alternatives: [alternatives[0]],
|
||||
alternatives: alternatives.length ? [alternatives[0]] : [],
|
||||
vendor: {
|
||||
name: 'deepgram',
|
||||
evt: copy
|
||||
@@ -487,14 +487,10 @@ module.exports = (logger) => {
|
||||
};
|
||||
|
||||
if ('google' === vendor) {
|
||||
const model = task.name === TaskName.Gather ? 'command_and_search' : 'latest_long';
|
||||
/**
|
||||
* When we support google v2 the models are different and we will want something like:
|
||||
* const useV2 = sttCredentials?.credentials?.project_id; //TODO: v2 pref should be set in googleOptions
|
||||
* const model = task.name === TaskName.Gather ?
|
||||
* (useV2 ? 'telephony_short' : 'command_and_search') :
|
||||
* (useV2 ? 'long' : 'latest_long');
|
||||
*/
|
||||
const useV2 = rOpts.googleOptions?.serviceVersion === 'v2';
|
||||
const model = task.name === TaskName.Gather ?
|
||||
(useV2 ? 'telephony_short' : 'command_and_search') :
|
||||
(useV2 ? 'long' : 'latest_long');
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials && {GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}),
|
||||
@@ -527,12 +523,26 @@ module.exports = (logger) => {
|
||||
...{GOOGLE_SPEECH_MODEL: rOpts.model || model},
|
||||
...(rOpts.naicsCode > 0 && {GOOGLE_SPEECH_METADATA_INDUSTRY_NAICS_CODE: rOpts.naicsCode}),
|
||||
GOOGLE_SPEECH_METADATA_RECORDING_DEVICE_TYPE: 'phone_line',
|
||||
/*
|
||||
...(useV2 && {
|
||||
GOOGLE_SPEECH_RECOGNIZER_PARENT: `projects/${sttCredentials.credentials.project_id}/locations/global`,
|
||||
GOOGLE_SPEECH_CLOUD_SERVICES_VERSION: 'v2'
|
||||
}),
|
||||
*/
|
||||
GOOGLE_SPEECH_CLOUD_SERVICES_VERSION: 'v2',
|
||||
...(rOpts.googleOptions?.speechStartTimeoutMs && {
|
||||
GOOGLE_SPEECH_START_TIMEOUT_MS: rOpts.googleOptions.speechStartTimeoutMs
|
||||
}),
|
||||
...(rOpts.googleOptions?.speechEndTimeoutMs && {
|
||||
GOOGLE_SPEECH_END_TIMEOUT_MS: rOpts.googleOptions.speechEndTimeoutMs
|
||||
}),
|
||||
...(rOpts.googleOptions?.transcriptNormalization && {
|
||||
GOOGLE_SPEECH_TRANSCRIPTION_NORMALIZATION: JSON.stringify(rOpts.googleOptions.transcriptNormalization)
|
||||
}),
|
||||
...(rOpts.googleOptions?.enableVoiceActivityEvents && {
|
||||
GOOGLE_SPEECH_ENABLE_VOICE_ACTIVITY_EVENTS: rOpts.googleOptions.enableVoiceActivityEvents
|
||||
}),
|
||||
...(rOpts.sgoogleOptions?.recognizerId) && {GOOGLE_SPEECH_RECOGNIZER_ID: rOpts.googleOptions.recognizerId},
|
||||
...(rOpts.googleOptions?.enableVoiceActivityEvents && {
|
||||
GOOGLE_SPEECH_ENABLE_VOICE_ACTIVITY_EVENTS: rOpts.googleOptions.enableVoiceActivityEvents
|
||||
}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
else if (['aws', 'polly'].includes(vendor)) {
|
||||
@@ -680,7 +690,9 @@ module.exports = (logger) => {
|
||||
...(deepgramOptions.keywords) &&
|
||||
{DEEPGRAM_SPEECH_KEYWORDS: deepgramOptions.keywords.join(',')},
|
||||
...('endpointing' in deepgramOptions) &&
|
||||
{DEEPGRAM_SPEECH_ENDPOINTING: deepgramOptions.endpointing === false ? 'false' : deepgramOptions.endpointing},
|
||||
{DEEPGRAM_SPEECH_ENDPOINTING: deepgramOptions.endpointing === false ? 'false' : deepgramOptions.endpointing,
|
||||
// default DEEPGRAM_SPEECH_UTTERANCE_END_MS is 1000, will be override by user settings later if there is.
|
||||
DEEPGRAM_SPEECH_UTTERANCE_END_MS: 1000},
|
||||
...(deepgramOptions.utteranceEndMs) &&
|
||||
{DEEPGRAM_SPEECH_UTTERANCE_END_MS: deepgramOptions.utteranceEndMs},
|
||||
...(deepgramOptions.vadTurnoff) &&
|
||||
|
||||
@@ -119,7 +119,7 @@ class WsRequestor extends BaseRequestor {
|
||||
type,
|
||||
msgid,
|
||||
call_sid: this.call_sid,
|
||||
hook: type === 'verb:hook' ? url : undefined,
|
||||
hook: ['verb:hook', 'session:redirect'].includes(type) ? url : undefined,
|
||||
data: {...payload},
|
||||
...b3
|
||||
};
|
||||
@@ -346,7 +346,9 @@ class WsRequestor extends BaseRequestor {
|
||||
/* messages must be JSON format */
|
||||
try {
|
||||
const obj = JSON.parse(content);
|
||||
const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
|
||||
//const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
|
||||
const {type, msgid, command, queueCommand = false, data} = obj;
|
||||
const call_sid = obj.callSid || this.call_sid;
|
||||
|
||||
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
|
||||
assert.ok(type, 'type property not supplied');
|
||||
|
||||
9675
package-lock.json
generated
9675
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
62
package.json
62
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "0.8.6",
|
||||
"version": "0.9.0",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 18.x"
|
||||
@@ -25,57 +25,57 @@
|
||||
"jslint:fix": "eslint app.js tracer.js lib --fix"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-auto-scaling": "^3.360.0",
|
||||
"@aws-sdk/client-sns": "^3.360.0",
|
||||
"@aws-sdk/client-auto-scaling": "^3.549.0",
|
||||
"@aws-sdk/client-sns": "^3.549.0",
|
||||
"@jambonz/db-helpers": "^0.9.3",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.4",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.7",
|
||||
"@jambonz/speech-utils": "^0.0.42",
|
||||
"@jambonz/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.8",
|
||||
"@jambonz/speech-utils": "^0.0.51",
|
||||
"@jambonz/stats-collector": "^0.1.9",
|
||||
"@jambonz/time-series": "^0.2.8",
|
||||
"@jambonz/verb-specifications": "^0.0.63",
|
||||
"@opentelemetry/api": "^1.4.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.9.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.35.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.9.0",
|
||||
"@opentelemetry/instrumentation": "^0.35.0",
|
||||
"@opentelemetry/resources": "^1.9.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.9.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.9.0",
|
||||
"@jambonz/verb-specifications": "^0.0.69",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
|
||||
"@opentelemetry/exporter-zipkin": "^1.23.0",
|
||||
"@opentelemetry/instrumentation": "^0.50.0",
|
||||
"@opentelemetry/resources": "^1.23.0",
|
||||
"@opentelemetry/sdk-trace-base": "^1.23.0",
|
||||
"@opentelemetry/sdk-trace-node": "^1.23.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.23.0",
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^3.0.38",
|
||||
"drachtio-fsmrf": "^3.0.40",
|
||||
"drachtio-srf": "^4.5.31",
|
||||
"express": "^4.18.2",
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"ip": "^1.1.9",
|
||||
"moment": "^2.29.4",
|
||||
"parse-url": "^8.1.0",
|
||||
"pino": "^8.8.0",
|
||||
"ip": "^2.0.1",
|
||||
"moment": "^2.30.1",
|
||||
"parse-url": "^9.2.0",
|
||||
"pino": "^8.20.0",
|
||||
"polly-ssml-split": "^0.1.0",
|
||||
"proxyquire": "^2.1.3",
|
||||
"sdp-transform": "^2.14.1",
|
||||
"sdp-transform": "^2.14.2",
|
||||
"short-uuid": "^4.2.2",
|
||||
"sinon": "^15.0.1",
|
||||
"sinon": "^17.0.1",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"undici": "^5.28.3",
|
||||
"undici": "^6.11.1",
|
||||
"uuid-random": "^1.3.2",
|
||||
"verify-aws-sns-signature": "^0.1.0",
|
||||
"ws": "^8.9.0",
|
||||
"ws": "^8.16.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"clear-module": "^4.1.2",
|
||||
"eslint": "^7.32.0",
|
||||
"eslint-plugin-promise": "^4.3.1",
|
||||
"eslint": "7.32.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"nyc": "^15.1.0",
|
||||
"tape": "^5.6.1"
|
||||
"tape": "^5.7.5"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"bufferutil": "^4.0.6",
|
||||
"utf-8-validate": "^5.0.8"
|
||||
"bufferutil": "^4.0.8",
|
||||
"utf-8-validate": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
freeswitch:
|
||||
image: drachtio/drachtio-freeswitch-mrf:0.6.2
|
||||
image: drachtio/drachtio-freeswitch-mrf:0.7.3
|
||||
restart: always
|
||||
command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100
|
||||
environment:
|
||||
|
||||
Reference in New Issue
Block a user