mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-01-25 02:07:56 +00:00
Compare commits
129 Commits
bug/714
...
fix/deepgr
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
380cd7f792 | ||
|
|
6889f0e4ab | ||
|
|
1efb198f72 | ||
|
|
4b5df855e1 | ||
|
|
24126ef1ec | ||
|
|
8e4995ec02 | ||
|
|
a005253a9f | ||
|
|
10efc5d608 | ||
|
|
1c48c40496 | ||
|
|
c79a6aaf8a | ||
|
|
da5f51e8e0 | ||
|
|
e7fd40e297 | ||
|
|
f541ff1a15 | ||
|
|
98b968d61f | ||
|
|
f09722a5b5 | ||
|
|
f84b3793e1 | ||
|
|
84b7456c2d | ||
|
|
c67499e38b | ||
|
|
e372a3cdfb | ||
|
|
ea303caa1c | ||
|
|
2af67d8f05 | ||
|
|
96b3b0fe07 | ||
|
|
b898b70520 | ||
|
|
b9ef00dfc7 | ||
|
|
68fa3c013d | ||
|
|
7c24208067 | ||
|
|
7f7c26e982 | ||
|
|
402adc2098 | ||
|
|
724d4fb713 | ||
|
|
673827cce3 | ||
|
|
c4c5ad33d8 | ||
|
|
7bbfc01cb0 | ||
|
|
7daf056d6b | ||
|
|
e69afc4be4 | ||
|
|
3a7cc27d0a | ||
|
|
c4a6057fc6 | ||
|
|
174438bb01 | ||
|
|
4348615b75 | ||
|
|
d365883bfe | ||
|
|
c0ab936b76 | ||
|
|
600ff763fa | ||
|
|
4d077e990f | ||
|
|
eccef54b04 | ||
|
|
2790e6d9ad | ||
|
|
f95d8639be | ||
|
|
fc838512b6 | ||
|
|
68992bccf6 | ||
|
|
c131fceea7 | ||
|
|
12174359f2 | ||
|
|
020c84d2df | ||
|
|
62d71d2504 | ||
|
|
c594797cb0 | ||
|
|
bae96a6752 | ||
|
|
ee68575ea4 | ||
|
|
6d0aeff6e2 | ||
|
|
d2a5d483d0 | ||
|
|
d3eb106d5d | ||
|
|
689e55bdf0 | ||
|
|
ed7e036890 | ||
|
|
f90fcdf57b | ||
|
|
c2a1819cbb | ||
|
|
4259a24fa0 | ||
|
|
e4e37d5697 | ||
|
|
b7a3c2970a | ||
|
|
cc33ac1d51 | ||
|
|
4b4807e4cf | ||
|
|
9a3c731389 | ||
|
|
edd8f20642 | ||
|
|
ee24041cba | ||
|
|
83f7abcd89 | ||
|
|
c9194168d2 | ||
|
|
83191487cf | ||
|
|
65ef4e6d64 | ||
|
|
ddb4719220 | ||
|
|
f514a65f63 | ||
|
|
5ccea65b7f | ||
|
|
8672152873 | ||
|
|
425b88f930 | ||
|
|
111976bea5 | ||
|
|
ec6d7b3f42 | ||
|
|
5e1b826da4 | ||
|
|
be9c3406c1 | ||
|
|
2f3ef1654a | ||
|
|
0baa080a1e | ||
|
|
f5cbd26c9f | ||
|
|
d9fd82fa60 | ||
|
|
76a3aa7f42 | ||
|
|
cafe149bdf | ||
|
|
9969e39e7e | ||
|
|
8eea212df2 | ||
|
|
e8e356ea3a | ||
|
|
c5e19bf775 | ||
|
|
498dd64025 | ||
|
|
24b6d2464b | ||
|
|
cd5421120f | ||
|
|
d7c3a4a632 | ||
|
|
c53ad89154 | ||
|
|
10b98630d3 | ||
|
|
d132bdb92b | ||
|
|
6be3fd9b64 | ||
|
|
844b0cb05d | ||
|
|
c0b56d4fc6 | ||
|
|
d27de284e7 | ||
|
|
5e97847a2f | ||
|
|
17c379df47 | ||
|
|
e7bc0b0737 | ||
|
|
dfe623e78a | ||
|
|
56b8f0623b | ||
|
|
7bcbab5b74 | ||
|
|
44e6a3513d | ||
|
|
fad16144b9 | ||
|
|
6523a861c0 | ||
|
|
cff67f5e4c | ||
|
|
c77bd84e0e | ||
|
|
3cd7a619ad | ||
|
|
59cf02bd04 | ||
|
|
a18d55e9ab | ||
|
|
d474b9d604 | ||
|
|
8d2b60c284 | ||
|
|
9cf9d4f587 | ||
|
|
bd002ede48 | ||
|
|
1a2aa91973 | ||
|
|
e322b7d8d3 | ||
|
|
7da11df88e | ||
|
|
09cf1345f6 | ||
|
|
2595f527ff | ||
|
|
1d77c0cd20 | ||
|
|
9eab81268b | ||
|
|
ecf3d140d6 |
9
.github/workflows/build.yml
vendored
9
.github/workflows/build.yml
vendored
@@ -6,12 +6,17 @@ jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
- run: npm ci
|
||||
- run: npm run jslint
|
||||
- name: Install Docker Compose
|
||||
run: |
|
||||
sudo curl -L "https://github.com/docker/compose/releases/download/1.29.2/docker-compose-$(uname -s)-$(uname -m)" -o /usr/local/bin/docker-compose
|
||||
sudo chmod +x /usr/local/bin/docker-compose
|
||||
docker-compose --version
|
||||
- run: docker pull drachtio/sipp
|
||||
- run: npm test
|
||||
env:
|
||||
|
||||
2
LICENSE
2
LICENSE
@@ -1,6 +1,6 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) 2021 Drachtio Communications Services, LLC
|
||||
Copyright (c) 2018-2024 FirstFive8, Inc.
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
||||
@@ -21,6 +21,7 @@ Configuration is provided via environment variables:
|
||||
|ENCRYPTION_SECRET| secret for credential encryption(JWT_SECRET is deprecated) |yes|
|
||||
|GOOGLE_APPLICATION_CREDENTIALS| path to gcp service key file|yes|
|
||||
|HTTP_PORT| tcp port to listen on for API requests from jambonz-api-server|yes|
|
||||
|HTTP_IP| IP Address for API requests from jambonz-api-server |no|
|
||||
|JAMBONES_GATHER_EARLY_HINTS_MATCH| if true and hints are provided, gather will opportunistically review interim transcripts if possible to reduce ASR latency |no|
|
||||
|JAMBONES_FREESWITCH| IP:port:secret for Freeswitch server (e.g. '127.0.0.1:8021:JambonzR0ck$'|yes|
|
||||
|JAMBONES_LOGLEVEL| log level for application, 'info' or 'debug'|no|
|
||||
|
||||
7
app.js
7
app.js
@@ -100,8 +100,13 @@ createHttpListener(logger, srf)
|
||||
});
|
||||
|
||||
|
||||
setInterval(() => {
|
||||
setInterval(async() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
// Checking system log level
|
||||
const systemInformation = await srf.locals.dbHelpers.lookupSystemInformation();
|
||||
if (systemInformation && systemInformation.log_level) {
|
||||
logger.level = systemInformation.log_level;
|
||||
}
|
||||
}, 20000);
|
||||
|
||||
const disconnect = () => {
|
||||
|
||||
@@ -9,7 +9,112 @@
|
||||
"can't take your call",
|
||||
"will get back to you",
|
||||
"I'll get back to you",
|
||||
"we are unable"
|
||||
"we are unable",
|
||||
"Unable to take your call now",
|
||||
"I'll reply soon",
|
||||
"I'll call back",
|
||||
"I'll reach out to you as soon as possible",
|
||||
"Leave a message",
|
||||
"Away from phone",
|
||||
"Not available now",
|
||||
"I'll return call",
|
||||
"On another call",
|
||||
"Currently on another call",
|
||||
"I will return call later",
|
||||
"Busy please leave message",
|
||||
"Message will be returned promptly",
|
||||
"Currently unavailable to answer",
|
||||
"Planning to return your call soon",
|
||||
"Apologies for missing your call",
|
||||
"Not by the phone at the moment",
|
||||
"Expecting to return your call",
|
||||
"Currently not accessible",
|
||||
"Intend to call back",
|
||||
"Appreciate your patience!",
|
||||
"Engaged in another conversation",
|
||||
"I Will respond promptly",
|
||||
"Kindly leave a message",
|
||||
"Currently occupied leave a message",
|
||||
"Unfortunately unable to answer right now",
|
||||
"Occupied at the moment",
|
||||
"Not present leave a message",
|
||||
"Regrettably unavailable kindly leave a message",
|
||||
"Will ensure a prompt response to your message",
|
||||
"Currently engaged",
|
||||
"Will return your call at the earliest opportunity",
|
||||
"Your message will receive my prompt attention",
|
||||
"I'll respond as soon as I can",
|
||||
"Your message is important please leave it after the beep",
|
||||
"Away from the phone at the moment",
|
||||
"Unable to answer right now",
|
||||
"Engaged in another task",
|
||||
"Not by the phone presently",
|
||||
"I'll respond at my earliest convenience",
|
||||
"Away from the phone momentarily",
|
||||
"I'll return your call shortly",
|
||||
"Currently not able to answer",
|
||||
"Your message is important please leave it after the tone",
|
||||
"I'm unable to take your call right now",
|
||||
"Please leave your message for me",
|
||||
"I'll get back to you soon",
|
||||
"Your call has been missed",
|
||||
"Please leave a detailed message for me to respond to",
|
||||
"Leave a message I'll make sure to respond",
|
||||
"Feel free to leave a message",
|
||||
"Your call is important to me",
|
||||
"I'll get back to you shortly",
|
||||
"Your message will be attended to promptly",
|
||||
"Not available at the moment",
|
||||
"I'll be sure to get back to you",
|
||||
"I'll call you back soon",
|
||||
"I'll ensure a prompt response",
|
||||
"Sorry for the inconvenience",
|
||||
"I'll return your call",
|
||||
"I'll make sure to get back to you",
|
||||
"I'll call you back shortly",
|
||||
"I'll return your call as soon as possible",
|
||||
"Apologies for the inconvenience leave your message",
|
||||
"Your call is appreciated",
|
||||
"I'm unavailable to answer",
|
||||
"I'm currently away",
|
||||
"I'll return your call as soon as I can",
|
||||
"I'm away from the phone",
|
||||
"I'm currently unavailable to take your call",
|
||||
"Sorry for missing your call",
|
||||
"I'll ensure it receives my immediate attention",
|
||||
"I'm away from the phone momentarily",
|
||||
"I'll reach out to you shortly",
|
||||
"Apologies for the inconvenience",
|
||||
"Currently occupied",
|
||||
"Unable to answer your call at the moment",
|
||||
"I'll make sure to follow up with you",
|
||||
"Sorry for not being available",
|
||||
"I'll reach out to you as soon as I can",
|
||||
"I'm currently engaged",
|
||||
"I'm currently busy",
|
||||
"I'm currently unavailable",
|
||||
"I'll respond to you at my earliest convenience",
|
||||
"Your message is appreciated",
|
||||
"I'll get back to you promptly",
|
||||
"I'll get back to you without delay",
|
||||
"Currently away from the phone",
|
||||
"I'll return your call at my earliest opportunity",
|
||||
"Sorry for the missed call",
|
||||
"I'll make sure to address your concerns",
|
||||
"Please provide your details for a callback",
|
||||
"I'll make every effort to respond promptly",
|
||||
"I'll ensure it's attended to promptly",
|
||||
"Away from the phone temporarily",
|
||||
"I'll get back to you as soon as I return",
|
||||
"Currently not in a position to answer your call",
|
||||
"Your call cannot be answered at the moment",
|
||||
"I'll ensure to respond as soon as I'm able",
|
||||
"Your call is important please leave a message",
|
||||
"Unable to answer right now please leave your message",
|
||||
"Currently not accessible intending to return your call",
|
||||
"I'll respond promptly to your message",
|
||||
"leave a memo",
|
||||
"please leave a memo"
|
||||
],
|
||||
"es-ES": [
|
||||
"le pasamos la llamada",
|
||||
|
||||
@@ -73,6 +73,7 @@ const JAMBONES_LOGLEVEL = process.env.JAMBONES_LOGLEVEL || 'info';
|
||||
const JAMBONES_INJECT_CONTENT = process.env.JAMBONES_INJECT_CONTENT;
|
||||
|
||||
const PORT = parseInt(process.env.HTTP_PORT, 10) || 3000;
|
||||
const HTTP_IP = process.env.HTTP_IP;
|
||||
const HTTP_PORT_MAX = parseInt(process.env.HTTP_PORT_MAX, 10);
|
||||
|
||||
const K8S = process.env.K8S;
|
||||
@@ -107,6 +108,8 @@ const DEEPGRAM_API_KEY = process.env.DEEPGRAM_API_KEY;
|
||||
const ANCHOR_MEDIA_ALWAYS = process.env.ANCHOR_MEDIA_ALWAYS;
|
||||
const VMD_HINTS_FILE = process.env.VMD_HINTS_FILE;
|
||||
|
||||
const JAMBONES_AWS_TRANSCRIBE_USE_GRPC = process.env.JAMBONES_AWS_TRANSCRIBE_USE_GRPC;
|
||||
|
||||
/* security, secrets */
|
||||
const LEGACY_CRYPTO = !!process.env.LEGACY_CRYPTO;
|
||||
const JWT_SECRET = process.env.JWT_SECRET;
|
||||
@@ -170,6 +173,7 @@ module.exports = {
|
||||
JAMBONES_CLUSTER_ID,
|
||||
PORT,
|
||||
HTTP_PORT_MAX,
|
||||
HTTP_IP,
|
||||
K8S,
|
||||
K8S_SBC_SIP_SERVICE_NAME,
|
||||
JAMBONES_SUBNET,
|
||||
@@ -188,6 +192,7 @@ module.exports = {
|
||||
ANCHOR_MEDIA_ALWAYS,
|
||||
VMD_HINTS_FILE,
|
||||
JAMBONES_FREESWITCH_MAX_CALL_DURATION_MINS,
|
||||
JAMBONES_AWS_TRANSCRIBE_USE_GRPC,
|
||||
|
||||
LEGACY_CRYPTO,
|
||||
JWT_SECRET,
|
||||
|
||||
@@ -30,6 +30,20 @@ const appsMap = {
|
||||
}
|
||||
]
|
||||
}]
|
||||
},
|
||||
conference: {
|
||||
// Dummy hook to follow later feature server logic.
|
||||
call_hook: {
|
||||
url: 'https://jambonz.org',
|
||||
method: 'GET'
|
||||
},
|
||||
account_sid: '',
|
||||
app_json: [{
|
||||
verb: 'conference',
|
||||
name: '',
|
||||
beep: false,
|
||||
startConferenceOnEnter: true
|
||||
}]
|
||||
}
|
||||
};
|
||||
|
||||
@@ -38,6 +52,7 @@ const createJambonzApp = (type, {account_sid, name, caller_id}) => {
|
||||
app.account_sid = account_sid;
|
||||
switch (type) {
|
||||
case 'queue':
|
||||
case 'conference':
|
||||
app.app_json[0].name = name;
|
||||
break;
|
||||
case 'user':
|
||||
|
||||
@@ -97,7 +97,8 @@ router.post('/',
|
||||
'X-Trace-ID': rootSpan.traceId,
|
||||
...(req.body?.application_sid && {'X-Application-Sid': req.body.application_sid}),
|
||||
...(restDial.fromHost && {'X-Preferred-From-Host': restDial.fromHost}),
|
||||
...(record_all_calls && {'X-Record-All-Calls': recordOutputFormat})
|
||||
...(record_all_calls && {'X-Record-All-Calls': recordOutputFormat}),
|
||||
...target.headers
|
||||
};
|
||||
|
||||
switch (target.type) {
|
||||
@@ -218,7 +219,7 @@ router.post('/',
|
||||
}
|
||||
if (!app.notifier && app.call_status_hook) {
|
||||
app.notifier = new HttpRequestor(logger, account.account_sid, app.call_status_hook, account.webhook_secret);
|
||||
logger.debug({call_hook: app.call_hook}, 'creating http client for call status hook');
|
||||
logger.debug({call_status_hook: app.call_status_hook}, 'creating http client for call status hook');
|
||||
}
|
||||
else if (!app.notifier) {
|
||||
logger.debug('creating null call status hook');
|
||||
@@ -257,6 +258,8 @@ router.post('/',
|
||||
callId: inviteReq.get('Call-ID'),
|
||||
accountSid,
|
||||
traceId: rootSpan.traceId
|
||||
}, {
|
||||
...(account.enable_debug_log && {level: 'debug'})
|
||||
});
|
||||
app.requestor.logger = app.notifier.logger = sipLogger;
|
||||
const callInfo = new CallInfo({
|
||||
@@ -290,6 +293,8 @@ router.post('/',
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const callStatus = prov.body ? CallStatus.EarlyMedia : CallStatus.Ringing;
|
||||
// Update call-id for sbc outbound INVITE
|
||||
cs.callInfo.sbcCallid = prov.get('X-CID');
|
||||
if ([180, 183].includes(prov.status) && prov.body) connectStream(prov.body);
|
||||
restDial.emit('callStatus', prov.status, !!prov.body);
|
||||
cs.emit('callStatusChange', {callStatus, sipStatus: prov.status});
|
||||
|
||||
@@ -75,13 +75,19 @@ module.exports = function(srf, logger) {
|
||||
req.locals.application_sid = application_sid;
|
||||
}
|
||||
// check for call to queue
|
||||
if (uri.user?.startsWith('queue-') && req.locals.originatingUser && clientDb?.allow_direct_queue_calling) {
|
||||
else if (uri.user?.startsWith('queue-') && req.locals.originatingUser && clientDb?.allow_direct_queue_calling) {
|
||||
const queue_name = uri.user.match(/queue-(.*)/)[1];
|
||||
logger.debug(`got Queue from Request URI header: ${queue_name}`);
|
||||
req.locals.queue_name = queue_name;
|
||||
}
|
||||
// check for call to conference
|
||||
else if (uri.user?.startsWith('conference-') && req.locals.originatingUser && clientDb?.allow_direct_app_calling) {
|
||||
const conference_id = uri.user.match(/conference-(.*)/)[1];
|
||||
logger.debug(`got Conference from Request URI header: ${conference_id}`);
|
||||
req.locals.conference_id = conference_id;
|
||||
}
|
||||
// check for call to registered user
|
||||
if (!JAMBONES_DISABLE_DIRECT_P2P_CALL && req.locals.originatingUser && clientDb?.allow_direct_user_calling) {
|
||||
else if (!JAMBONES_DISABLE_DIRECT_P2P_CALL && req.locals.originatingUser && clientDb?.allow_direct_user_calling) {
|
||||
const arr = /^(.*)@(.*)/.exec(req.locals.originatingUser);
|
||||
if (arr) {
|
||||
const sipRealm = arr[2];
|
||||
@@ -181,14 +187,20 @@ module.exports = function(srf, logger) {
|
||||
|
||||
const {span} = rootSpan.startChildSpan('lookupAccountDetails');
|
||||
try {
|
||||
req.locals.accountInfo = await lookupAccountDetails(account_sid);
|
||||
req.locals.service_provider_sid = req.locals.accountInfo?.account?.service_provider_sid;
|
||||
const accountDetail = await lookupAccountDetails(account_sid);
|
||||
const account = accountDetail?.account;
|
||||
req.locals.accountInfo = accountDetail;
|
||||
req.locals.service_provider_sid = account?.service_provider_sid;
|
||||
span.end();
|
||||
if (!req.locals.accountInfo.account.is_active) {
|
||||
if (!account?.is_active) {
|
||||
logger.info(`Account is inactive or suspended ${account_sid}`);
|
||||
// TODO: alert
|
||||
return res.send(503, {headers: {'X-Reason': 'Account exists but is inactive'}});
|
||||
}
|
||||
// Change the default log level to debug
|
||||
if (account?.enable_debug_log) {
|
||||
req.locals.logger.level = 'debug';
|
||||
}
|
||||
logger.debug({accountInfo: req.locals?.accountInfo?.account}, `retrieved account info for ${account_sid}`);
|
||||
next();
|
||||
} catch (err) {
|
||||
@@ -237,6 +249,9 @@ module.exports = function(srf, logger) {
|
||||
logger.debug(`calling to registered user ${req.locals.called_user}, generating dial app`);
|
||||
app = createJambonzApp('user',
|
||||
{account_sid, name: req.locals.called_user, caller_id: req.locals.callingNumber});
|
||||
} else if (req.locals.conference_id) {
|
||||
logger.debug(`calling to conference ${req.locals.conference_id}, generating conference app`);
|
||||
app = createJambonzApp('conference', {account_sid, name: req.locals.conference_id});
|
||||
} else if (req.locals.application_sid) {
|
||||
app = await lookupAppBySid(req.locals.application_sid);
|
||||
} else if (req.locals.originatingUser) {
|
||||
@@ -343,6 +358,17 @@ module.exports = function(srf, logger) {
|
||||
direction: CallDirection.Inbound,
|
||||
traceId: rootSpan.traceId
|
||||
});
|
||||
// if transferred call contains callInfo, let update original data to newly created callInfo in this instance.
|
||||
if (app.transferredCall && app.callInfo) {
|
||||
const {direction, callerName, from, to, originatingSipIp, originatingSipTrunkName} = app.callInfo;
|
||||
req.locals.callInfo.direction = direction;
|
||||
req.locals.callInfo.callerName = callerName;
|
||||
req.locals.callInfo.from = from;
|
||||
req.locals.callInfo.to = to;
|
||||
req.locals.callInfo.originatingSipIp = originatingSipIp;
|
||||
req.locals.callInfo.originatingSipTrunkName = originatingSipTrunkName;
|
||||
delete app.callInfo;
|
||||
}
|
||||
next();
|
||||
} catch (err) {
|
||||
span.end();
|
||||
@@ -359,7 +385,7 @@ module.exports = function(srf, logger) {
|
||||
const {rootSpan, siprec, application:app} = req.locals;
|
||||
let span;
|
||||
try {
|
||||
if (app.tasks && !JAMBONES_MYSQL_REFRESH_TTL) {
|
||||
if (app.tasks && app.tasks?.length > 0 && !JAMBONES_MYSQL_REFRESH_TTL) {
|
||||
app.tasks = normalizeJambones(logger, app.tasks).map((tdata) => makeTask(logger, tdata));
|
||||
if (0 === app.tasks.length) throw new Error('no application provided');
|
||||
return next();
|
||||
|
||||
@@ -32,6 +32,7 @@ class CallInfo {
|
||||
this.sipStatus = 100;
|
||||
this.sipReason = 'Trying';
|
||||
this.callStatus = CallStatus.Trying;
|
||||
this.sbcCallid = req.get('X-CID');
|
||||
this.originatingSipIp = req.get('X-Forwarded-For');
|
||||
this.originatingSipTrunkName = req.get('X-Originating-Carrier');
|
||||
const {siprec} = req.locals;
|
||||
@@ -129,6 +130,7 @@ class CallInfo {
|
||||
from: this.from,
|
||||
to: this.to,
|
||||
callId: this.callId,
|
||||
sbcCallid: this.sbcCallid,
|
||||
sipStatus: this.sipStatus,
|
||||
sipReason: this.sipReason,
|
||||
callStatus: this.callStatus,
|
||||
|
||||
@@ -19,6 +19,8 @@ const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const listTaskNames = require('../utils/summarize-tasks');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const ActionHookDelayProcessor = require('../utils/action-hook-delay');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
const {
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
JAMBONES_EAGERLY_PRE_CACHE_AUDIO,
|
||||
@@ -115,6 +117,7 @@ class CallSession extends Emitter {
|
||||
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('reconnect-error', this._onSessionReconnectError.bind(this));
|
||||
};
|
||||
|
||||
if (!this.isConfirmCallSession) {
|
||||
@@ -122,6 +125,7 @@ class CallSession extends Emitter {
|
||||
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('reconnect-error', this._onSessionReconnectError.bind(this));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -193,6 +197,24 @@ class CallSession extends Emitter {
|
||||
this._synthesizer = synth;
|
||||
}
|
||||
|
||||
/**
|
||||
* ASR TTS fallback
|
||||
*/
|
||||
get hasFallbackAsr() {
|
||||
return this._hasFallbackAsr || false;
|
||||
}
|
||||
|
||||
set hasFallbackAsr(i) {
|
||||
this._hasFallbackAsr = i;
|
||||
}
|
||||
|
||||
get hasFallbackTts() {
|
||||
return this._hasFallbackTts || false;
|
||||
}
|
||||
|
||||
set hasFallbackTts(i) {
|
||||
this._hasFallbackTts = i;
|
||||
}
|
||||
/**
|
||||
* default vendor to use for speech synthesis if not provided in the app
|
||||
*/
|
||||
@@ -318,6 +340,29 @@ class CallSession extends Emitter {
|
||||
this.application.fallback_speech_recognizer_language = language;
|
||||
}
|
||||
|
||||
/**
|
||||
* global referHook
|
||||
*/
|
||||
|
||||
set referHook(hook) {
|
||||
this._referHook = hook;
|
||||
}
|
||||
|
||||
get referHook() {
|
||||
return this._referHook;
|
||||
}
|
||||
|
||||
/**
|
||||
* Vad
|
||||
*/
|
||||
get vad() {
|
||||
return this._vad;
|
||||
}
|
||||
|
||||
set vad(v) {
|
||||
this._vad = v;
|
||||
}
|
||||
|
||||
/**
|
||||
* indicates whether the call currently in progress
|
||||
*/
|
||||
@@ -488,14 +533,80 @@ class CallSession extends Emitter {
|
||||
this._actionHookDelayRetries = e;
|
||||
}
|
||||
|
||||
get actionHookDelayActions() {
|
||||
return this._actionHookDelayActions;
|
||||
get actionHookDelayProcessor() {
|
||||
return this._actionHookDelayProcessor;
|
||||
}
|
||||
|
||||
set actionHookDelayActions(e) {
|
||||
this._actionHookDelayActions = e;
|
||||
set actionHookDelayProperties(opts) {
|
||||
if (this._actionHookDelayProcessor) {
|
||||
this._actionHookDelayProcessor.stop();
|
||||
if (!this._actionHookDelayProcessor.init(opts)) {
|
||||
this._actionHookDelayProcessor.removeAllListeners();
|
||||
this._actionHookDelayProcessor = null;
|
||||
}
|
||||
}
|
||||
else {
|
||||
try {
|
||||
this._actionHookDelayProcessor = new ActionHookDelayProcessor(this.logger, opts, this, this.ep);
|
||||
this._actionHookDelayProcessor.on('giveup', () => {
|
||||
this.logger.info('CallSession: ActionHookDelayProcessor: giveup event - hanging up call');
|
||||
this._jambonzHangup();
|
||||
if (this.wakeupResolver) {
|
||||
this.logger.debug('CallSession: Giveup timer expired - waking up');
|
||||
this.wakeupResolver({reason: 'noResponseGiveUp'});
|
||||
this.wakeupResolver = null;
|
||||
}
|
||||
});
|
||||
this._actionHookDelayProcessor.on('giveupWithTasks', (tasks) => {
|
||||
this.logger.info('CallSession: ActionHookDelayProcessor: giveupWithTasks event');
|
||||
const giveUpTasks = normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata));
|
||||
this.logger.info({tasks: listTaskNames(giveUpTasks)}, 'CallSession:giveupWithTasks task list');
|
||||
|
||||
// we need to clear the ahd, as we do not want to execute actionHookDelay actions again
|
||||
this.clearActionHookDelayProcessor();
|
||||
|
||||
// replace the application with giveUpTasks
|
||||
this.replaceApplication(giveUpTasks);
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'CallSession: Error creating ActionHookDelayProcessor');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async clearOrRestoreActionHookDelayProcessor() {
|
||||
if (this._actionHookDelayProcessor) {
|
||||
await this._actionHookDelayProcessor.stop();
|
||||
if (!this.popActionHookDelayProperties()) {
|
||||
//this.logger.debug('CallSession:clearOrRestoreActionHookDelayProcessor - ahd settings');
|
||||
//await this.clearActionHookDelayProcessor();
|
||||
}
|
||||
this.logger.debug('CallSession:clearOrRestoreActionHookDelayProcessor - say or play action completed');
|
||||
}
|
||||
}
|
||||
|
||||
async clearActionHookDelayProcessor() {
|
||||
if (this._actionHookDelayProcessor) {
|
||||
await this._actionHookDelayProcessor.stop();
|
||||
this._actionHookDelayProcessor.removeAllListeners();
|
||||
this._actionHookDelayProcessor = null;
|
||||
}
|
||||
}
|
||||
|
||||
stashActionHookDelayProperties() {
|
||||
this._storedActionHookDelayProperties = this._actionHookDelayProcessor.properties;
|
||||
}
|
||||
|
||||
popActionHookDelayProperties() {
|
||||
if (this._storedActionHookDelayProperties) {
|
||||
this._actionHookDelayProcessor.init(this._storedActionHookDelayProperties);
|
||||
this._storedActionHookDelayProperties = null;
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
hasGlobalSttPunctuation() {
|
||||
return this._globalSttPunctuation !== undefined;
|
||||
}
|
||||
@@ -702,7 +813,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);
|
||||
@@ -775,7 +886,8 @@ class CallSession extends Emitter {
|
||||
writeAlerts({
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
account_sid: this.accountSid,
|
||||
vendor
|
||||
vendor,
|
||||
target_sid: this.callSid
|
||||
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
|
||||
}
|
||||
}
|
||||
@@ -784,6 +896,7 @@ class CallSession extends Emitter {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
accessKeyId: credential.access_key_id,
|
||||
secretAccessKey: credential.secret_access_key,
|
||||
roleArn: credential.role_arn,
|
||||
region: credential.aws_region || AWS_REGION
|
||||
};
|
||||
}
|
||||
@@ -820,6 +933,7 @@ class CallSession extends Emitter {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
api_key: credential.api_key,
|
||||
deepgram_stt_uri: credential.deepgram_stt_uri,
|
||||
deepgram_tts_uri: credential.deepgram_tts_uri,
|
||||
deepgram_stt_use_tls: credential.deepgram_stt_use_tls
|
||||
};
|
||||
}
|
||||
@@ -849,30 +963,56 @@ class CallSession extends Emitter {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
cobalt_server_uri: credential.cobalt_server_uri
|
||||
};
|
||||
} else if ('elevenlabs' === vendor) {
|
||||
}
|
||||
else if ('elevenlabs' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
options: credential.options
|
||||
};
|
||||
} else if ('playht' === vendor) {
|
||||
}
|
||||
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 ('assemblyai' === vendor) {
|
||||
}
|
||||
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,
|
||||
api_key: credential.api_key
|
||||
};
|
||||
} else if ('whisper' === vendor) {
|
||||
}
|
||||
else if ('whisper' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id
|
||||
};
|
||||
} else if (vendor.startsWith('custom:')) {
|
||||
}
|
||||
else if ('verbio' === vendor) {
|
||||
return {
|
||||
client_id: credential.client_id,
|
||||
client_secret: credential.client_secret,
|
||||
engine_version: credential.engine_version
|
||||
};
|
||||
}
|
||||
else if ('speechmatics' === vendor) {
|
||||
this.logger.info({credential}, 'CallSession:getSpeechCredentials - speechmatics credential');
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
speechmatics_stt_uri: credential.speechmatics_stt_uri,
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
auth_token: credential.auth_token,
|
||||
@@ -885,7 +1025,8 @@ class CallSession extends Emitter {
|
||||
writeAlerts({
|
||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||
account_sid: this.accountSid,
|
||||
vendor
|
||||
vendor,
|
||||
target_sid: this.callSid
|
||||
}).catch((err) => this.logger.error({err}, 'Error writing tts alert'));
|
||||
}
|
||||
}
|
||||
@@ -909,7 +1050,6 @@ class CallSession extends Emitter {
|
||||
task.on('VerbHookSpanWaitForEnd', ({span}) => {
|
||||
this.verbHookSpan = span;
|
||||
});
|
||||
task.on('ActionHookDelayActionOptions', this._onActionHookDelayActions.bind(this));
|
||||
try {
|
||||
const resources = await this._evaluatePreconditions(task);
|
||||
let skip = false;
|
||||
@@ -955,6 +1095,15 @@ class CallSession extends Emitter {
|
||||
) {
|
||||
try {
|
||||
await this._awaitCommandsOrHangup();
|
||||
|
||||
//await this.clearOrRestoreActionHookDelayProcessor();
|
||||
|
||||
//TODO: remove filler noise code and simply create as action hook delay
|
||||
if (this._isPlayingFillerNoise) {
|
||||
this._isPlayingFillerNoise = false;
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing filler noise'));
|
||||
}
|
||||
if (this.callGone) break;
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'CallSession:exec - error waiting for new commands');
|
||||
@@ -1180,9 +1329,8 @@ class CallSession extends Emitter {
|
||||
this.currentTask.kill(this);
|
||||
}
|
||||
this._endVerbHookSpan();
|
||||
// clear all delay action hook timeout if there is
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
this._clearActionHookNoResponseTimer();
|
||||
|
||||
await this.clearOrRestoreActionHookDelayProcessor();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -1241,7 +1389,7 @@ class CallSession extends Emitter {
|
||||
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');
|
||||
return this.logger.info('CallSession:_lccConferenceParticipantAction - invalid cmd, call is not in conference');
|
||||
}
|
||||
task.doConferenceParticipantAction(this, opts);
|
||||
}
|
||||
@@ -1441,6 +1589,29 @@ Duration=${duration} `
|
||||
this.logger.info({response}, '_lccBoostAudioSignal: response from freeswitch');
|
||||
}
|
||||
|
||||
_lccToolOutput(tool_call_id, opts, callSid) {
|
||||
// only valid if we are in an LLM verb
|
||||
const task = this.currentTask;
|
||||
if (!task || !task.name.startsWith('Llm')) {
|
||||
return this.logger.info('CallSession:_lccToolOutput - invalid command since we are not in an llm');
|
||||
}
|
||||
|
||||
task.processToolOutput(tool_call_id, opts, callSid)
|
||||
.catch((err) => this.logger.error(err, 'CallSession:_lccToolOutput'));
|
||||
}
|
||||
|
||||
|
||||
_lccLlmUpdate(opts, callSid) {
|
||||
// only valid if we are in an LLM verb
|
||||
const task = this.currentTask;
|
||||
if (!task || !task.name.startsWith('Llm')) {
|
||||
return this.logger.info('CallSession:_lccLlmUpdate - invalid command since we are not in an llm');
|
||||
}
|
||||
|
||||
task.processLlmUpdate(opts, callSid)
|
||||
.catch((err) => this.logger.error(err, 'CallSession:_lccLlmUpdate'));
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* perform call hangup by jambonz
|
||||
@@ -1493,7 +1664,7 @@ Duration=${duration} `
|
||||
return this._lccTag(opts);
|
||||
}
|
||||
else if (opts.conferenceParticipantAction) {
|
||||
return this._lccConferenceParticipantState(opts);
|
||||
return this._lccConferenceParticipantAction(opts.conferenceParticipantAction);
|
||||
}
|
||||
else if (opts.dub) {
|
||||
return this._lccDub(opts);
|
||||
@@ -1501,6 +1672,12 @@ Duration=${duration} `
|
||||
else if (opts.boostAudioSignal) {
|
||||
return this._lccBoostAudioSignal(opts, callSid);
|
||||
}
|
||||
else if (opts.llm_tool_output) {
|
||||
return this._lccToolOutput(opts.tool_call_id, opts.llm_tool_output, callSid);
|
||||
}
|
||||
else if (opts.llm_update) {
|
||||
return this._lccLlmUpdate(opts.llm_update, 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..
|
||||
@@ -1592,7 +1769,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;
|
||||
@@ -1664,7 +1857,24 @@ Duration=${duration} `
|
||||
}, 'CallSession:_injectTasks - completed');
|
||||
}
|
||||
|
||||
_onCommand({msgid, command, call_sid, queueCommand, data}) {
|
||||
async _onSessionReconnectError(err) {
|
||||
const {writeAlerts, AlertType} = this.srf.locals;
|
||||
const sid = this.accountInfo.account.account_sid;
|
||||
this.logger.info({err}, `_onSessionReconnectError for account ${sid}`);
|
||||
try {
|
||||
await writeAlerts({
|
||||
alert_type: AlertType.WEBHOOK_CONNECTION_FAILURE,
|
||||
account_sid: this.accountSid,
|
||||
detail: `Session:reconnect error ${err}`,
|
||||
url: this.application.call_hook.url,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error({error}, 'Error writing WEBHOOK_CONNECTION_FAILURE alert');
|
||||
}
|
||||
this._jambonzHangup();
|
||||
}
|
||||
|
||||
async _onCommand({msgid, command, call_sid, queueCommand, tool_call_id, data}) {
|
||||
this.logger.info({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
|
||||
let resolution;
|
||||
switch (command) {
|
||||
@@ -1689,9 +1899,9 @@ Duration=${duration} `
|
||||
}
|
||||
resolution = {reason: 'received command, new tasks', queue: queueCommand, command};
|
||||
resolution.command = listTaskNames(t);
|
||||
|
||||
// clear all delay action hook timeout if there is
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
this._clearActionHookNoResponseTimer();
|
||||
await this.clearOrRestoreActionHookDelayProcessor();
|
||||
}
|
||||
else this._lccCallHook(data);
|
||||
break;
|
||||
@@ -1765,6 +1975,14 @@ Duration=${duration} `
|
||||
});
|
||||
break;
|
||||
|
||||
case 'llm:tool-output':
|
||||
this._lccToolOutput(tool_call_id, data, call_sid);
|
||||
break;
|
||||
|
||||
case 'llm:update':
|
||||
this._lccLlmUpdate(data, call_sid);
|
||||
break;
|
||||
|
||||
default:
|
||||
this.logger.info(`CallSession:_onCommand - invalid command ${command}`);
|
||||
}
|
||||
@@ -1844,7 +2062,7 @@ Duration=${duration} `
|
||||
});
|
||||
//ep.cs = this;
|
||||
this.ep = ep;
|
||||
this.logger.debug(`allocated endpoint ${ep.uuid}`);
|
||||
this.logger.info(`allocated endpoint ${ep.uuid}`);
|
||||
|
||||
this._configMsEndpoint();
|
||||
|
||||
@@ -1917,6 +2135,12 @@ Duration=${duration} `
|
||||
this.logger.error('CallSession:replaceEndpoint cannot be called without stable dlg');
|
||||
return;
|
||||
}
|
||||
// When this call kicked out from conference, session need to replace endpoint
|
||||
// but this.ms might be undefined/null at this case.
|
||||
this.ms = this.ms || this.getMS();
|
||||
// Destroy previous ep if it's still running.
|
||||
if (this.ep?.connected) this.ep.destroy();
|
||||
|
||||
this.ep = await this.ms.createEndpoint({remoteSdp: this.dlg.remote.sdp});
|
||||
this._configMsEndpoint();
|
||||
|
||||
@@ -1951,13 +2175,13 @@ Duration=${duration} `
|
||||
this.rootSpan && this.rootSpan.end();
|
||||
// close all background tasks
|
||||
this.backgroundTaskManager.stopAll();
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
this._clearActionHookNoResponseTimer();
|
||||
this.clearOrRestoreActionHookDelayProcessor().catch((err) => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* called when the caller has hung up. Provided for subclasses to override
|
||||
* in order to apply logic at this point if needed.
|
||||
* return true if success fallback, return false if not
|
||||
*/
|
||||
_callerHungup() {
|
||||
assert(false, 'subclass responsibility to override this method');
|
||||
@@ -2074,17 +2298,59 @@ Duration=${duration} `
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle incoming REFER if we are in a dial task
|
||||
* Handle incoming REFER
|
||||
* @param {*} req
|
||||
* @param {*} res
|
||||
*/
|
||||
_onRefer(req, res) {
|
||||
const task = this.currentTask;
|
||||
const sd = task.sd;
|
||||
if (task && TaskName.Dial === task.name && sd) {
|
||||
if (task && TaskName.Dial === task.name && sd && task.referHook) {
|
||||
task.handleRefer(this, req, res);
|
||||
}
|
||||
else {
|
||||
this._handleRefer(req, res);
|
||||
}
|
||||
}
|
||||
|
||||
async _handleRefer(req, res) {
|
||||
if (this._referHook) {
|
||||
try {
|
||||
const to = parseUri(req.getParsedHeader('Refer-To').uri);
|
||||
const by = parseUri(req.getParsedHeader('Referred-By').uri);
|
||||
const b3 = this.b3;
|
||||
const httpHeaders = b3 && {b3};
|
||||
const json = await this.requestor.request('verb:hook', this._referHook, {
|
||||
...(this.callInfo.toJSON()),
|
||||
refer_details: {
|
||||
sip_refer_to: req.get('Refer-To'),
|
||||
sip_referred_by: req.get('Referred-By'),
|
||||
sip_user_agent: req.get('User-Agent'),
|
||||
refer_to_user: to.scheme === 'tel' ? to.number : to.user,
|
||||
referred_by_user: by.scheme === 'tel' ? by.number : by.user,
|
||||
referring_call_sid: this.callSid,
|
||||
referred_call_sid: null,
|
||||
}
|
||||
}, httpHeaders);
|
||||
|
||||
if (json && Array.isArray(json)) {
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.logger.info('CallSession:handleRefer received REFER, get new tasks');
|
||||
this.replaceApplication(tasks);
|
||||
if (this.wakeupResolver) {
|
||||
this.wakeupResolver({reason: 'CallSession: referHook new taks'});
|
||||
this.wakeupResolver = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
res.send(202);
|
||||
this.logger.info('CallSession:handleRefer - sent 202 Accepted');
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'CallSession:handleRefer - error while asking referHook');
|
||||
res.send(err.statusCode || 501);
|
||||
}
|
||||
} else {
|
||||
res.send(501);
|
||||
}
|
||||
}
|
||||
@@ -2359,6 +2625,30 @@ Duration=${duration} `
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info('_awaitCommandsOrHangup - waiting...');
|
||||
this.wakeupResolver = resolve;
|
||||
|
||||
if (this._actionHookDelayProcessor) {
|
||||
this._actionHookDelayProcessor.start();
|
||||
}
|
||||
|
||||
/**
|
||||
* TODO: filler noise can be handled as an ActionHookDelayProcessor -
|
||||
* it's just one specific scenario for action hook delay -
|
||||
* remove the code below and simply implement filler noise as an action hook delay
|
||||
*/
|
||||
|
||||
/* start filler noise if configured while we wait for new commands */
|
||||
if (this.fillerNoise?.url && this.ep?.connected && !this.ep2) {
|
||||
this.logger.debug('CallSession:_awaitCommandsOrHangup - playing filler noise');
|
||||
this._isPlayingFillerNoise = true;
|
||||
this.ep.play(this.fillerNoise.url);
|
||||
this.ep.once('playback-start', (evt) => {
|
||||
if (evt.file === this.fillerNoise.url && !this._isPlayingFillerNoise) {
|
||||
this.logger.info('CallSession:_awaitCommandsOrHangup - filler noise started');
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing filler noise'));
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2390,69 +2680,6 @@ Duration=${duration} `
|
||||
this.verbHookSpan = null;
|
||||
}
|
||||
}
|
||||
// actionHook delay actions
|
||||
_onActionHookDelayActions(options) {
|
||||
this._actionHookDelayRetryCount = 0;
|
||||
this._startActionHookNoResponseTimer(options);
|
||||
this._startActionHookNoResponseGiveUpTimer(options);
|
||||
}
|
||||
|
||||
_startActionHookNoResponseTimer(options) {
|
||||
this._clearActionHookNoResponseTimer();
|
||||
if (options.noResponseTimeoutMs) {
|
||||
this.logger.debug(`CallSession:_startActionHookNoResponseTimer ${options.noResponseTimeoutMs}`);
|
||||
this._actionHookNoResponseTimer = setTimeout(() => {
|
||||
if (this._actionHookDelayRetryCount >= options.retries) {
|
||||
this._jambonzHangup();
|
||||
}
|
||||
const verb = options.actions[this._actionHookDelayRetryCount % options.actions.length];
|
||||
// Inject verb to main stack
|
||||
const t = normalizeJambones(this.logger, [verb])
|
||||
.map((tdata) => makeTask(this.logger, tdata));
|
||||
if (t.length) {
|
||||
t[0].on('playDone', (err) => {
|
||||
if (err) this.logger.error({err}, `Call-Session:exec Error delay action, play ${verb}`);
|
||||
this._startActionHookNoResponseTimer(options);
|
||||
});
|
||||
}
|
||||
this.tasks.push(...t);
|
||||
if (this.wakeupResolver) {
|
||||
this.wakeupResolver({reason: 'actionHook no response, applied delay actions', verb});
|
||||
this.wakeupResolver = null;
|
||||
}
|
||||
|
||||
this.logger.debug(`CallSession:_startActionHookNoResponseTimer, executing verb ${JSON.stringify(verb)}`);
|
||||
|
||||
this._actionHookDelayRetryCount++;
|
||||
}, options.noResponseTimeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
_clearActionHookNoResponseTimer() {
|
||||
if (this._actionHookNoResponseTimer) {
|
||||
clearTimeout(this._actionHookNoResponseTimer);
|
||||
}
|
||||
this._actionHookNoResponseTimer = null;
|
||||
}
|
||||
|
||||
_startActionHookNoResponseGiveUpTimer(options) {
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
if (options.noResponseGiveUpTimeoutMs) {
|
||||
this.logger.debug(`CallSession:_startActionHookNoResponseGiveUpTimer ${options.noResponseGiveUpTimeoutMs}`);
|
||||
this._actionHookNoResponseGiveUpTimer = setTimeout(() => {
|
||||
this.logger.debug('CallSession:_startActionHookNoResponseGiveUpTimer Timeout');
|
||||
this._jambonzHangup();
|
||||
this._actionHookNoResponseGiveUpTimer = null;
|
||||
}, options.noResponseGiveUpTimeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
_clearActionHookNoResponseGiveUpTimer() {
|
||||
if (this._actionHookNoResponseGiveUpTimer) {
|
||||
clearTimeout(this._actionHookNoResponseGiveUpTimer);
|
||||
}
|
||||
this._actionHookNoResponseGiveUpTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CallSession;
|
||||
|
||||
@@ -71,7 +71,9 @@ class InboundCallSession extends CallSession {
|
||||
}
|
||||
|
||||
_jambonzHangup() {
|
||||
this._hangup();
|
||||
this.dlg?.destroy();
|
||||
// kill current task or wakeup the call session.
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
_hangup(terminatedBy = 'jambonz') {
|
||||
@@ -79,6 +81,7 @@ class InboundCallSession extends CallSession {
|
||||
this.logger.info('InboundCallSession:_hangup - race condition, dlg cleared by app hangup');
|
||||
return;
|
||||
}
|
||||
this.logger.info(`InboundCallSession: ${terminatedBy} hung up`);
|
||||
assert(this.dlg.connectTime);
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.rootSpan.setAttributes({'call.termination': `hangup by ${terminatedBy}`});
|
||||
@@ -87,7 +90,6 @@ class InboundCallSession extends CallSession {
|
||||
callStatus: CallStatus.Completed,
|
||||
duration
|
||||
});
|
||||
this.logger.info(`InboundCallSession: ${terminatedBy} hung up`);
|
||||
this._callReleased();
|
||||
this.req.removeAllListeners('cancel');
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const CallSession = require('./call-session');
|
||||
const {CallStatus} = require('../utils/constants');
|
||||
const moment = require('moment');
|
||||
|
||||
/**
|
||||
* @classdesc Subclass of CallSession. This represents a CallSession that is
|
||||
* created for an outbound call that is initiated via the REST API.
|
||||
@@ -42,9 +41,10 @@ class RestCallSession extends CallSession {
|
||||
setDialog(dlg) {
|
||||
this.dlg = dlg;
|
||||
dlg.on('destroy', this._callerHungup.bind(this));
|
||||
dlg.on('refer', this._onRefer.bind(this));
|
||||
dlg.on('modify', this._onReinvite.bind(this));
|
||||
this.wrapDialog(dlg);
|
||||
}
|
||||
|
||||
/**
|
||||
* This is invoked when the called party hangs up, in order to calculate the call duration.
|
||||
*/
|
||||
@@ -56,7 +56,7 @@ class RestCallSession extends CallSession {
|
||||
this._hangup();
|
||||
}
|
||||
|
||||
_hangup(terminatedBy = 'jamboz') {
|
||||
_hangup(terminatedBy = 'jambonz') {
|
||||
if (this.restDialTask) {
|
||||
this.restDialTask.turnOffAmd();
|
||||
}
|
||||
|
||||
@@ -6,6 +6,7 @@ const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const makeTask = require('./make_task');
|
||||
const bent = require('bent');
|
||||
const assert = require('assert');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
const WAIT = 'wait';
|
||||
const JOIN = 'join';
|
||||
const START = 'start';
|
||||
@@ -60,6 +61,8 @@ class Conference extends Task {
|
||||
|
||||
this.emitter = new Emitter();
|
||||
this.results = {};
|
||||
this.coaching = [];
|
||||
this.speakOnlyTo = this.data.speakOnlyTo;
|
||||
|
||||
// transferred from another server in order to bridge to a local caller?
|
||||
if (this.data._ && this.data._.connectTime) {
|
||||
@@ -115,7 +118,9 @@ class Conference extends Task {
|
||||
this.emitter.emit('kill');
|
||||
await this._doFinalMemberCheck(cs);
|
||||
if (this.ep && this.ep.connected) {
|
||||
this.ep.conn.removeAllListeners('esl::event::CUSTOM::*');
|
||||
// drachtio-fsmrf override esl::event::CUSTOM to conference join listerner, After finish the conference
|
||||
// the application need to reset the esl::event::CUSTOM for another use on the same endpoint
|
||||
this.ep.resetEslCustomEvent();
|
||||
this.ep.api(`conference ${this.confName} kick ${this.memberId}`)
|
||||
.catch((err) => this.logger.info({err}, 'Error kicking participant'));
|
||||
}
|
||||
@@ -132,15 +137,10 @@ class Conference extends Task {
|
||||
* @param {SipDialog} dlg
|
||||
*/
|
||||
async _init(cs, dlg) {
|
||||
const friendlyName = this.confName;
|
||||
const {createHash, retrieveHash} = cs.srf.locals.dbHelpers;
|
||||
this.friendlyName = this.confName;
|
||||
this.confName = `conf:${cs.accountSid}:${this.confName}`;
|
||||
|
||||
this.statusParams = Object.assign({
|
||||
conferenceSid: this.confName,
|
||||
friendlyName
|
||||
}, cs.callInfo);
|
||||
|
||||
// check if conference is in progress
|
||||
const obj = await retrieveHash(this.confName);
|
||||
if (obj) {
|
||||
@@ -348,7 +348,9 @@ class Conference extends Task {
|
||||
Object.assign(opts, {flags: {
|
||||
...(this.endConferenceOnExit && {endconf: true}),
|
||||
...(this.startConferenceOnEnter && {moderator: true}),
|
||||
...((this.joinMuted || this.data.speakOnlyTo) && {joinMuted: true}),
|
||||
//https://developer.signalwire.com/freeswitch/FreeSWITCH-Explained/Modules/mod_conference_3965534/
|
||||
// mute | Enter conference muted
|
||||
...((this.joinMuted || this.speakOnlyTo) && {mute: true}),
|
||||
}});
|
||||
|
||||
/**
|
||||
@@ -361,7 +363,7 @@ class Conference extends Task {
|
||||
try {
|
||||
const {memberId, confUuid} = await this.ep.join(this.confName, opts);
|
||||
this.logger.debug({memberId, confUuid}, `Conference:_joinConference: successfully joined ${this.confName}`);
|
||||
this.memberId = memberId;
|
||||
this.memberId = parseInt(memberId, 10);
|
||||
this.confUuid = confUuid;
|
||||
|
||||
// set a tag for this member, if provided
|
||||
@@ -395,8 +397,8 @@ class Conference extends Task {
|
||||
.catch((err) => {});
|
||||
}
|
||||
|
||||
if (this.data.speakOnlyTo) {
|
||||
this.setCoachMode(this.data.speakOnlyTo);
|
||||
if (this.speakOnlyTo) {
|
||||
this.setCoachMode(this.speakOnlyTo);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error(err, `Failed to join conference ${this.confName}`);
|
||||
@@ -488,7 +490,7 @@ class Conference extends Task {
|
||||
}
|
||||
|
||||
async doConferenceParticipantAction(cs, opts) {
|
||||
const {action, tag} = opts;
|
||||
const {action, tag, wait_hook } = opts;
|
||||
|
||||
switch (action) {
|
||||
case 'tag':
|
||||
@@ -504,7 +506,10 @@ class Conference extends Task {
|
||||
await this.clearCoachMode();
|
||||
break;
|
||||
case 'hold':
|
||||
this.doConferenceHold(cs, {conf_hold_status: 'hold'});
|
||||
this.doConferenceHold(cs, {
|
||||
conf_hold_status: 'hold',
|
||||
...(wait_hook && {wait_hook})
|
||||
});
|
||||
break;
|
||||
case 'unhold':
|
||||
this.doConferenceHold(cs, {conf_hold_status: 'unhold'});
|
||||
@@ -515,8 +520,11 @@ class Conference extends Task {
|
||||
case 'unmute':
|
||||
this.doConferenceMute(cs, {conf_mute_status: 'unmute'});
|
||||
break;
|
||||
case 'kick':
|
||||
this.kickMember(cs);
|
||||
break;
|
||||
default:
|
||||
this.logger.info(`Conference:doConferenceParticipantState - unhandled action ${action}`);
|
||||
this.logger.info(`Conference:doConferenceParticipantAction - unhandled action ${action}`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
@@ -538,6 +546,13 @@ class Conference extends Task {
|
||||
} while (!this.killed && this.conf_hold_status === 'hold');
|
||||
}
|
||||
|
||||
/**
|
||||
* mute or unmute side of the call
|
||||
*/
|
||||
mute(callSid, doMute) {
|
||||
this.doConferenceMute(this.callSession, {conf_mute_status: doMute});
|
||||
}
|
||||
|
||||
/**
|
||||
* Add ourselves to the waitlist of sessions to be notified once
|
||||
* the conference starts
|
||||
@@ -567,7 +582,7 @@ class Conference extends Task {
|
||||
_normalizeHook(cs, hook) {
|
||||
if (typeof hook === 'object') return hook;
|
||||
const url = hook.startsWith('/') ?
|
||||
`${cs.application.requestor.baseUrl}${hook}` :
|
||||
`${cs.application.requestor instanceof HttpRequestor ? cs.application.requestor.baseUrl : ''}${hook}` :
|
||||
hook;
|
||||
|
||||
return { url } ;
|
||||
@@ -586,7 +601,7 @@ class Conference extends Task {
|
||||
const response = await this.ep.api('conference', [this.confName, 'get', 'count']);
|
||||
if (response.body && confNoMatch(response.body)) this.participantCount = 0;
|
||||
else if (response.body && /^\d+$/.test(response.body)) this.participantCount = parseInt(response.body) - 1;
|
||||
this.logger.debug({response}, `Conference:_doFinalMemberCheck conference count ${this.participantCount}`);
|
||||
this.logger.debug(`Conference:_doFinalMemberCheck conference count ${this.participantCount}`);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Conference:_doFinalMemberCheck error retrieving count (we were probably kicked');
|
||||
}
|
||||
@@ -596,7 +611,7 @@ class Conference extends Task {
|
||||
* when we hang up as the last member, the current member count = 1
|
||||
* when we are kicked out of the call when the moderator leaves, the member count = 0
|
||||
*/
|
||||
if (this.participantCount === 0) {
|
||||
if (this.participantCount === 0 || this.endConferenceOnExit) {
|
||||
const {deleteKey} = cs.srf.locals.dbHelpers;
|
||||
try {
|
||||
this._notifyConferenceEvent(cs, 'end');
|
||||
@@ -604,7 +619,8 @@ class Conference extends Task {
|
||||
this.logger.info(`conf ${this.confName} deprovisioned: ${removed ? 'success' : 'failure'}`);
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.error(err, `Error deprovisioning conference ${this.confName}`);
|
||||
this.logger.error(err, `Error deprovisioning conference ${this.confName},
|
||||
might be the conference already cleaned by another moderator`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -681,8 +697,24 @@ class Conference extends Task {
|
||||
if (!params.time) params.time = (new Date()).toISOString();
|
||||
if (!params.members && typeof this.participantCount === 'number') params.members = this.participantCount;
|
||||
cs.application.requestor
|
||||
.request('verb:hook', this.statusHook, Object.assign(params, this.statusParams, httpHeaders))
|
||||
.catch((err) => this.logger.info(err, 'Conference:notifyConferenceEvent - error'));
|
||||
.request(
|
||||
'verb:hook',
|
||||
this.statusHook,
|
||||
Object.assign(
|
||||
params,
|
||||
Object.assign(
|
||||
{
|
||||
conferenceSid: this.confName,
|
||||
friendlyName: this.friendlyName,
|
||||
},
|
||||
cs.callInfo.toJSON()
|
||||
),
|
||||
httpHeaders
|
||||
)
|
||||
)
|
||||
.catch((err) =>
|
||||
this.logger.info(err, 'Conference:notifyConferenceEvent - error')
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -699,7 +731,12 @@ class Conference extends Task {
|
||||
|
||||
// conference event handlers
|
||||
_onAddMember(logger, cs, evt) {
|
||||
logger.debug({evt}, `Conference:_onAddMember - member added to conference ${this.confName}`);
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
if (this.speakOnlyTo) {
|
||||
logger.debug(`Conference:_onAddMember - member ${memberId} added to ${this.confName}, updating coaching mode`);
|
||||
this.setCoachMode(this.speakOnlyTo).catch(() => {});
|
||||
}
|
||||
else logger.debug(`Conference:_onAddMember - member ${memberId} added to conference ${this.confName}`);
|
||||
}
|
||||
_onDelMember(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
@@ -734,28 +771,64 @@ class Conference extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
_onTag(logger, cs, evt) {
|
||||
const memberId = parseInt(evt.getHeader('Member-ID')) ;
|
||||
const tag = evt.getHeader('Tag') || '';
|
||||
if (memberId !== this.memberId && this.speakOnlyTo) {
|
||||
logger.info(`Conference:_onTag - member ${memberId} set tag to '${tag }'; updating coach mode accordingly`);
|
||||
this.setCoachMode(this.speakOnlyTo).catch(() => {});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the conference to "coaching" mode, where the audio of the participant is only heard
|
||||
* by a subset of the participants in the conference.
|
||||
* We do this by first getting all of the members who do *not* have this tag, and then
|
||||
* we configure this members audio to not be sent to them.
|
||||
* @param {string} speakOnlyTo - tag of the members who should receive our audio
|
||||
*
|
||||
* N.B.: this feature requires jambonz patches to freeswitch mod_conference
|
||||
*/
|
||||
async setCoachMode(speakOnlyTo) {
|
||||
this.speakOnlyTo = speakOnlyTo;
|
||||
if (!this.memberId) {
|
||||
this.logger.info('Conference:_setCoachMode: no member id yet');
|
||||
return;
|
||||
}
|
||||
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;
|
||||
const members = (await this.ep.getNonMatchingConfParticipants(this.confName, speakOnlyTo))
|
||||
.filter((m) => m !== this.memberId);
|
||||
if (members.length === 0) {
|
||||
this.logger.info({members}, 'Conference:_setCoachMode: all participants have the tag, so all will hear me');
|
||||
if (this.coaching.length) {
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, this.coaching.join(','), 'clear']);
|
||||
this.coaching = [];
|
||||
}
|
||||
}
|
||||
else {
|
||||
const memberList = members.join(',');
|
||||
this.logger.info(`Conference:_setCoachMode: my audio will NOT be sent to ${memberList}`);
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, memberList, 'nospeak']);
|
||||
this.coaching = members;
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.error({err, speakOnlyTo}, '_setCoachMode: Error');
|
||||
}
|
||||
}
|
||||
|
||||
async clearCoachMode() {
|
||||
if (!this.memberId) return;
|
||||
try {
|
||||
if (!this.coaching) {
|
||||
if (this.coaching.length === 0) {
|
||||
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']);
|
||||
else {
|
||||
const memberList = this.coaching.join(',');
|
||||
this.logger.info(`Conference:_clearCoachMode: now sending my audio to all, including ${memberList}`);
|
||||
await this.ep.api('conference', [this.confName, 'relate', this.memberId, memberList, 'clear']);
|
||||
}
|
||||
this.speakOnlyTo = null;
|
||||
this.coaching = null;
|
||||
this.coaching = [];
|
||||
} catch (err) {
|
||||
this.logger.error({err}, '_clearCoachMode: Error');
|
||||
}
|
||||
@@ -781,6 +854,16 @@ class Conference extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
async kickMember(cs) {
|
||||
assert(cs.isInConference);
|
||||
try {
|
||||
await this.ep.api('conference', [this.confName, 'kick', this.memberId]);
|
||||
this.logger.info(`Conference:kickMember: kick ${this.memberId} out of conference ${this.confName}`);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, `Error kicking member out of conference for ${this.memberId}`);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = Conference;
|
||||
|
||||
@@ -15,7 +15,8 @@ class TaskConfig extends Task {
|
||||
'transcribe',
|
||||
'fillerNoise',
|
||||
'actionHookDelayAction',
|
||||
'boostAudioSignal'
|
||||
'boostAudioSignal',
|
||||
'vad'
|
||||
].forEach((k) => this[k] = this.data[k] || {});
|
||||
|
||||
if ('notifyEvents' in this.data) {
|
||||
@@ -33,7 +34,8 @@ class TaskConfig extends Task {
|
||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'interDigitTimeout', 'bargein', 'dtmfBargein', 'minBargeinWordCount', 'actionHook'
|
||||
].forEach((k) => {
|
||||
if (this.bargeIn[k]) this.gatherOpts[k] = this.bargeIn[k];
|
||||
const val = this.bargeIn[k];
|
||||
if (val !== undefined && val !== null) this.gatherOpts[k] = val;
|
||||
});
|
||||
}
|
||||
if (this.transcribe?.enable) {
|
||||
@@ -70,7 +72,9 @@ class TaskConfig extends Task {
|
||||
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 hasVad() { return Object.keys(this.vad).length; }
|
||||
get hasFillerNoise() { return Object.keys(this.fillerNoise).length; }
|
||||
get hasReferHook() { return Object.keys(this.data).includes('referHook'); }
|
||||
|
||||
get summary() {
|
||||
const phrase = [];
|
||||
@@ -80,13 +84,13 @@ class TaskConfig extends Task {
|
||||
|
||||
if (this.bargeIn.enable) phrase.push('enable barge-in');
|
||||
if (this.hasSynthesizer) {
|
||||
const {vendor:v, language:l, voice} = this.synthesizer;
|
||||
const s = `{${v},${l},${voice}}`;
|
||||
const {vendor:v, language:l, voice, label} = this.synthesizer;
|
||||
const s = `{${v},${l},${voice},${label || 'None'}}`;
|
||||
phrase.push(`set synthesizer${s}`);
|
||||
}
|
||||
if (this.hasRecognizer) {
|
||||
const {vendor:v, language:l} = this.recognizer;
|
||||
const s = `{${v},${l}}`;
|
||||
const {vendor:v, language:l, label} = this.recognizer;
|
||||
const s = `{${v},${l},${label || 'None'}}`;
|
||||
phrase.push(`set recognizer${s}`);
|
||||
}
|
||||
if (this.hasRecording) phrase.push(this.record.action);
|
||||
@@ -101,6 +105,7 @@ class TaskConfig extends Task {
|
||||
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}`);
|
||||
if (this.hasReferHook) phrase.push('set referHook');
|
||||
return `${this.name}{${phrase.join(',')}}`;
|
||||
}
|
||||
|
||||
@@ -139,9 +144,8 @@ class TaskConfig extends Task {
|
||||
cs.speechSynthesisVendor = this.synthesizer.vendor !== 'default'
|
||||
? this.synthesizer.vendor
|
||||
: cs.speechSynthesisVendor;
|
||||
cs.speechSynthesisLabel = this.synthesizer.label !== 'default'
|
||||
? this.synthesizer.label
|
||||
: cs.speechSynthesisLabel;
|
||||
cs.speechSynthesisLabel = this.synthesizer.label === 'default'
|
||||
? cs.speechSynthesisLabel : this.synthesizer.label;
|
||||
cs.speechSynthesisLanguage = this.synthesizer.language !== 'default'
|
||||
? this.synthesizer.language
|
||||
: cs.speechSynthesisLanguage;
|
||||
@@ -153,15 +157,16 @@ class TaskConfig extends Task {
|
||||
cs.fallbackSpeechSynthesisVendor = this.synthesizer.fallbackVendor !== 'default'
|
||||
? this.synthesizer.fallbackVendor
|
||||
: cs.fallbackSpeechSynthesisVendor;
|
||||
cs.fallbackSpeechSynthesisLabel = this.synthesizer.fallbackLabel !== 'default'
|
||||
? this.synthesizer.fallbackLabel
|
||||
: cs.fallbackSpeechSynthesisLabel;
|
||||
cs.fallbackSpeechSynthesisLabel = this.synthesizer.fallbackLabel === 'default'
|
||||
? cs.fallbackSpeechSynthesisLabel : this.synthesizer.fallbackLabel;
|
||||
cs.fallbackSpeechSynthesisLanguage = this.synthesizer.fallbackLanguage !== 'default'
|
||||
? this.synthesizer.fallbackLanguage
|
||||
: cs.fallbackSpeechSynthesisLanguage;
|
||||
cs.fallbackSpeechSynthesisVoice = this.synthesizer.fallbackVoice !== 'default'
|
||||
? this.synthesizer.fallbackVoice
|
||||
: cs.fallbackSpeechSynthesisVoice;
|
||||
// new vendor is set, reset fallback vendor
|
||||
cs.hasFallbackTts = false;
|
||||
this.logger.info({synthesizer: this.synthesizer}, 'Config: updated synthesizer');
|
||||
}
|
||||
if (this.hasRecognizer) {
|
||||
@@ -169,9 +174,8 @@ class TaskConfig extends Task {
|
||||
cs.speechRecognizerVendor = this.recognizer.vendor !== 'default'
|
||||
? this.recognizer.vendor
|
||||
: cs.speechRecognizerVendor;
|
||||
cs.speechRecognizerLabel = this.recognizer.label !== 'default'
|
||||
? this.recognizer.label
|
||||
: cs.speechRecognizerLabel;
|
||||
cs.speechRecognizerLabel = this.recognizer.label === 'default'
|
||||
? cs.speechRecognizerLabel : this.recognizer.label;
|
||||
cs.speechRecognizerLanguage = this.recognizer.language !== 'default'
|
||||
? this.recognizer.language
|
||||
: cs.speechRecognizerLanguage;
|
||||
@@ -180,9 +184,9 @@ class TaskConfig extends Task {
|
||||
cs.fallbackSpeechRecognizerVendor = this.recognizer.fallbackVendor !== 'default'
|
||||
? this.recognizer.fallbackVendor
|
||||
: cs.fallbackSpeechRecognizerVendor;
|
||||
cs.fallbackSpeechRecognizerLabel = this.recognizer.fallbackLabel !== 'default'
|
||||
? this.recognizer.fallbackLabel
|
||||
: cs.fallbackSpeechRecognizerLabel;
|
||||
cs.fallbackSpeechRecognizerLabel = this.recognizer.fallbackLabel === 'default' ?
|
||||
cs.fallbackSpeechRecognizerLabel :
|
||||
this.recognizer.fallbackLabel;
|
||||
cs.fallbackSpeechRecognizerLanguage = this.recognizer.fallbackLanguage !== 'default'
|
||||
? this.recognizer.fallbackLanguage
|
||||
: cs.fallbackSpeechRecognizerLanguage;
|
||||
@@ -206,6 +210,8 @@ class TaskConfig extends Task {
|
||||
if ('punctuation' in this.recognizer) {
|
||||
cs.globalSttPunctuation = this.recognizer.punctuation;
|
||||
}
|
||||
// new vendor is set, reset fallback vendor
|
||||
cs.hasFallbackAsr = false;
|
||||
this.logger.info({
|
||||
recognizer: this.recognizer,
|
||||
isContinuousAsr: cs.isContinuousAsr
|
||||
@@ -246,12 +252,14 @@ class TaskConfig extends Task {
|
||||
}
|
||||
if (this.hasTranscribe) {
|
||||
if (this.transcribe.enable) {
|
||||
this.transcribeOpts.recognizer = this.hasRecognizer ?
|
||||
this.recognizer :
|
||||
{
|
||||
vendor: cs.speechRecognizerVendor,
|
||||
language: cs.speechRecognizerLanguage
|
||||
};
|
||||
if (!this.transcribeOpts.recognizer) {
|
||||
this.transcribeOpts.recognizer = this.hasRecognizer ?
|
||||
this.recognizer :
|
||||
{
|
||||
vendor: cs.speechRecognizerVendor,
|
||||
language: cs.speechRecognizerLanguage
|
||||
};
|
||||
}
|
||||
this.logger.debug(this.transcribeOpts, 'Config: enabling transcribe');
|
||||
cs.startBackgroundTask('transcribe', this.transcribeOpts);
|
||||
} else {
|
||||
@@ -259,13 +267,8 @@ class TaskConfig extends Task {
|
||||
cs.stopBackgroundTask('transcribe');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.actionHookDelayAction) {
|
||||
cs.actionHookDelayEnabled = this.actionHookDelayAction.enabled || false;
|
||||
cs.actionHookNoResponseTimeout = this.actionHookDelayAction.noResponseTimeout || 0;
|
||||
cs.actionHookNoResponseGiveUpTimeout = this.actionHookDelayAction.noResponseGiveUpTimeout || 0;
|
||||
cs.actionHookDelayRetries = this.actionHookDelayAction.retries || 1;
|
||||
cs.actionHookDelayActions = this.actionHookDelayAction.actions || [];
|
||||
if (Object.keys(this.actionHookDelayAction).length !== 0) {
|
||||
cs.actionHookDelayProperties = this.actionHookDelayAction;
|
||||
}
|
||||
if (this.data.sipRequestWithinDialogHook) {
|
||||
cs.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
||||
@@ -288,6 +291,20 @@ class TaskConfig extends Task {
|
||||
cs.enableFillerNoise(opts);
|
||||
}
|
||||
}
|
||||
|
||||
if (this.hasVad) {
|
||||
cs.vad = {
|
||||
enable: this.vad.enable || false,
|
||||
voiceMs: this.vad.voiceMs || 250,
|
||||
silenceMs: this.vad.silenceMs || 150,
|
||||
strategy: this.vad.strategy || 'one-shot',
|
||||
mode: (this.vad.mode !== undefined && this.vad.mode !== null) ? this.vad.mode : 2
|
||||
};
|
||||
}
|
||||
|
||||
if (this.hasReferHook) {
|
||||
cs.referHook = this.data.referHook;
|
||||
}
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
|
||||
@@ -82,6 +82,8 @@ function filterAndLimit(logger, tasks) {
|
||||
return unique;
|
||||
}
|
||||
|
||||
const sleepFor = (ms) => new Promise((resolve) => setTimeout(() => resolve(), ms));
|
||||
|
||||
class TaskDial extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
@@ -203,7 +205,16 @@ class TaskDial extends Task {
|
||||
else {
|
||||
this.epOther = cs.ep;
|
||||
if (this.dialMusic && this.epOther && this.epOther.connected) {
|
||||
this.epOther.play(this.dialMusic).catch((err) => {});
|
||||
(async() => {
|
||||
do {
|
||||
try {
|
||||
await this.epOther.play(this.dialMusic);
|
||||
} catch (err) {
|
||||
this.logger.error(err, `TaskDial:exec error playing ${this.dialMusic}`);
|
||||
await sleepFor(1000);
|
||||
}
|
||||
} while (!this.killed || !this.bridged);
|
||||
})();
|
||||
}
|
||||
}
|
||||
if (!this.killed) await this._attemptCalls(cs);
|
||||
@@ -329,15 +340,17 @@ class TaskDial extends Task {
|
||||
|
||||
const to = parseUri(req.getParsedHeader('Refer-To').uri);
|
||||
const by = parseUri(req.getParsedHeader('Referred-By').uri);
|
||||
const referredBy = req.get('Referred-By');
|
||||
const userAgent = req.get('User-Agent');
|
||||
this.logger.info({to}, 'refer to parsed');
|
||||
const json = await cs.requestor.request('verb:hook', this.referHook, {
|
||||
...(callInfo.toJSON()),
|
||||
refer_details: {
|
||||
sip_refer_to: req.get('Refer-To'),
|
||||
sip_referred_by: req.get('Referred-By'),
|
||||
sip_user_agent: req.get('User-Agent'),
|
||||
refer_to_user: to.scheme === 'tel' ? to.number : to.user,
|
||||
referred_by_user: by.scheme === 'tel' ? by.number : by.user,
|
||||
...(referredBy && {sip_referred_by: referredBy}),
|
||||
...(userAgent && {sip_user_agent: userAgent}),
|
||||
...(by && {referred_by_user: by.scheme === 'tel' ? by.number : by.user}),
|
||||
referring_call_sid,
|
||||
referred_call_sid
|
||||
}
|
||||
@@ -368,6 +381,7 @@ class TaskDial extends Task {
|
||||
res.send(202);
|
||||
this.logger.info('DialTask:handleRefer - sent 202 Accepted');
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'DialTask:handleRefer - error processing incoming REFER');
|
||||
res.send(err.statusCode || 501);
|
||||
}
|
||||
}
|
||||
@@ -473,7 +487,7 @@ class TaskDial extends Task {
|
||||
}
|
||||
|
||||
async _attemptCalls(cs) {
|
||||
const {req, srf} = cs;
|
||||
const {req, callInfo, direction, srf} = cs;
|
||||
const {getSBC} = srf.locals;
|
||||
const {lookupTeamsByAccount, lookupAccountBySid} = srf.locals.dbHelpers;
|
||||
const {lookupCarrier, lookupCarrierByPhoneNumber} = dbUtils(this.logger, cs.srf);
|
||||
@@ -485,8 +499,11 @@ class TaskDial extends Task {
|
||||
this.headers = {
|
||||
'X-Account-Sid': cs.accountSid,
|
||||
...(req && req.has('X-CID') && {'X-CID': req.get('X-CID')}),
|
||||
...(req && req.has('P-Asserted-Identity') && !JAMBONZ_DISABLE_DIAL_PAI_HEADER &&
|
||||
{'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
||||
...(direction === 'outbound' && callInfo.sbcCallid && {'X-CID': callInfo.sbcCallid}),
|
||||
...(!JAMBONZ_DISABLE_DIAL_PAI_HEADER && req && {
|
||||
...(req.has('P-Asserted-Identity') && {'P-Asserted-Identity': req.get('P-Asserted-Identity')}),
|
||||
...(req.has('Privacy') && {'Privacy': req.get('Privacy')}),
|
||||
}),
|
||||
...(req && req.has('X-Voip-Carrier-Sid') && {'X-Voip-Carrier-Sid': req.get('X-Voip-Carrier-Sid')}),
|
||||
// Put headers at the end to make sure opt.headers override all default behavior.
|
||||
...this.headers
|
||||
@@ -601,6 +618,7 @@ class TaskDial extends Task {
|
||||
dialCallStatus: obj.callStatus,
|
||||
dialSipStatus: obj.sipStatus,
|
||||
dialCallSid: sd.callSid,
|
||||
dialSbcCallid: sd.callInfo.sbcCallid
|
||||
});
|
||||
}
|
||||
switch (obj.callStatus) {
|
||||
@@ -636,6 +654,8 @@ class TaskDial extends Task {
|
||||
await this._connectSingleDial(cs, sd);
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Dial:_attemptCalls - Error calling _connectSingleDial ');
|
||||
sd.removeAllListeners();
|
||||
this.kill(cs);
|
||||
}
|
||||
})
|
||||
.on('decline', () => {
|
||||
|
||||
@@ -10,7 +10,10 @@ const {
|
||||
IbmTranscriptionEvents,
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
AssemblyAiTranscriptionEvents
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VadDetection,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
} = require('../utils/constants.json');
|
||||
const {
|
||||
JAMBONES_GATHER_EARLY_HINTS_MATCH,
|
||||
@@ -20,6 +23,7 @@ const {
|
||||
const makeTask = require('./make_task');
|
||||
const assert = require('assert');
|
||||
const SttTask = require('./stt-task');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
|
||||
class TaskGather extends SttTask {
|
||||
constructor(logger, opts, parentTask) {
|
||||
@@ -27,7 +31,7 @@ class TaskGather extends SttTask {
|
||||
[
|
||||
'finishOnKey', 'input', 'numDigits', 'minDigits', 'maxDigits',
|
||||
'interDigitTimeout', 'partialResultHook', 'bargein', 'dtmfBargein',
|
||||
'speechTimeout', 'timeout', 'say', 'play', 'actionHookDelayAction', 'fillerNoise'
|
||||
'speechTimeout', 'timeout', 'say', 'play', 'actionHookDelayAction', 'fillerNoise', 'vad'
|
||||
].forEach((k) => this[k] = this.data[k]);
|
||||
|
||||
// gather default input is digits
|
||||
@@ -41,7 +45,8 @@ class TaskGather extends SttTask {
|
||||
this.timeout = this.timeout === 0 ? 0 : (this.timeout || 15) * 1000;
|
||||
this.interim = !!this.partialResultHook || this.bargein || (this.timeout > 0);
|
||||
this.listenDuringPrompt = this.data.listenDuringPrompt === false ? false : true;
|
||||
this.minBargeinWordCount = this.data.minBargeinWordCount || 1;
|
||||
this.minBargeinWordCount = this.data.minBargeinWordCount !== undefined ? this.data.minBargeinWordCount : 1;
|
||||
this._vadEnabled = this.minBargeinWordCount === 0;
|
||||
if (this.data.recognizer) {
|
||||
/* continuous ASR (i.e. compile transcripts until a special timeout or dtmf key) */
|
||||
this.asrTimeout = typeof this.data.recognizer.asrTimeout === 'number' ?
|
||||
@@ -114,11 +119,25 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
if (this.sayTask) s += ',with nested say task';
|
||||
if (this.playTask) s += ',with nested play task';
|
||||
if (this.actionHookDelayAction) s += ',with actionHookDelayAction';
|
||||
s += '}';
|
||||
return s;
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
async exec(cs, obj) {
|
||||
try {
|
||||
await this.handling(cs, obj);
|
||||
} catch (error) {
|
||||
if (error instanceof SpeechCredentialError) {
|
||||
this.logger.info('Gather failed due to SpeechCredentialError, finished!');
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handling(cs, {ep}) {
|
||||
this.logger.debug({options: this.data}, 'Gather:exec');
|
||||
await super.exec(cs, {ep});
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
@@ -128,6 +147,11 @@ class TaskGather extends SttTask {
|
||||
...(this.fillerNoise || {})
|
||||
};
|
||||
|
||||
this.vad = {
|
||||
...(cs.vad || {}),
|
||||
...(this.vad || {})
|
||||
};
|
||||
|
||||
if (cs.hasGlobalSttHints && !this.maskGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
const setOfHints = new Set((this.data.recognizer.hints || [])
|
||||
@@ -155,27 +179,24 @@ class TaskGather extends SttTask {
|
||||
this.interim = true;
|
||||
this.logger.debug('Gather:exec - early hints match enabled');
|
||||
}
|
||||
// actionHook delay
|
||||
this._hookDelayEn = cs.actionHookDelayEnabled || !!this.actionHookDelayAction;
|
||||
|
||||
this._hookDelayActions = this.actionHookDelayAction?.actions || cs.actionHookDelayActions || [];
|
||||
// if we have actionHook delay, and the session does as well, stash the session config
|
||||
if (this.actionHookDelayAction) {
|
||||
if (cs.actionHookDelayProcessor) {
|
||||
this.logger.debug('Gather:exec - stashing session-level ahd proprerties');
|
||||
cs.stashActionHookDelayProperties();
|
||||
}
|
||||
cs.actionHookDelayProperties = this.actionHookDelayAction;
|
||||
}
|
||||
|
||||
// Only enable NoResponseTimeout if there is _hookDelayActions
|
||||
this._hookNoResponseTimeout = (this._hookDelayActions?.length ?
|
||||
(this.actionHookDelayAction?.noResponseTimeout || cs.actionHookNoResponseTimeout || 0)
|
||||
: 0) * 1000;
|
||||
this._startVad();
|
||||
|
||||
this._hookNoResponseGiveUpTimeout = (this.actionHookDelayAction?.noResponseGiveUpTimeout ||
|
||||
cs.actionHookNoResponseGiveUpTimeout || 0) * 1000;
|
||||
|
||||
this._hookDelayRetries = this.actionHookDelayAction?.retries || cs.actionHookDelayRetries || 1;
|
||||
this._hookDelayRetryCount = 0;
|
||||
this.hookDelayActionOpts = {
|
||||
enabled: this._hookDelayEn,
|
||||
actions: this._hookDelayActions,
|
||||
noResponseTimeoutMs: this._hookNoResponseTimeout,
|
||||
noResponseGiveUpTimeoutMs: this._hookNoResponseGiveUpTimeout,
|
||||
retries: this._hookDelayRetries
|
||||
const startDtmfListener = () => {
|
||||
assert(!this._dtmfListenerStarted);
|
||||
if (this.input.includes('digits') || this.dtmfBargein || this.asrDtmfTerminationDigit) {
|
||||
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
||||
this._dtmfListenerStarted = true;
|
||||
}
|
||||
};
|
||||
|
||||
const startListening = async(cs, ep) => {
|
||||
@@ -191,12 +212,7 @@ class TaskGather extends SttTask {
|
||||
this._startTranscribing(ep);
|
||||
return updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
||||
} catch (e) {
|
||||
if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
|
||||
await this._fallback();
|
||||
startListening(cs, ep);
|
||||
} else {
|
||||
this.logger.error({error: e}, 'error in initSpeech');
|
||||
}
|
||||
await this._startFallback(cs, ep, {error: e});
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -204,13 +220,12 @@ class TaskGather extends SttTask {
|
||||
try {
|
||||
if (this.sayTask) {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
|
||||
this.sayTask.span = span;
|
||||
this.sayTask.ctx = ctx;
|
||||
this.sayTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
const process = () => {
|
||||
this.logger.debug('Gather: nested say task completed');
|
||||
if (!this.listenDuringPrompt) {
|
||||
startDtmfListener();
|
||||
}
|
||||
this._stopVad();
|
||||
if (!this.killed) {
|
||||
startListening(cs, ep);
|
||||
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) {
|
||||
@@ -220,17 +235,27 @@ class TaskGather extends SttTask {
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
this.sayTask.span = span;
|
||||
this.sayTask.ctx = ctx;
|
||||
this.sayTask.exec(cs, {ep}) // kicked off, _not_ waiting for it to complete
|
||||
.catch((err) => {
|
||||
process();
|
||||
});
|
||||
this.sayTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing tts');
|
||||
process();
|
||||
});
|
||||
}
|
||||
else if (this.playTask) {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
||||
this.playTask.span = span;
|
||||
this.playTask.ctx = ctx;
|
||||
this.playTask.exec(cs, {ep}); // kicked off, _not_ waiting for it to complete
|
||||
this.playTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing url');
|
||||
const process = () => {
|
||||
this.logger.debug('Gather: nested play task completed');
|
||||
if (!this.listenDuringPrompt) {
|
||||
startDtmfListener();
|
||||
}
|
||||
this._stopVad();
|
||||
if (!this.killed) {
|
||||
startListening(cs, ep);
|
||||
if (this.input.includes('speech') && this.vendor === 'nuance' && this.listenDuringPrompt) {
|
||||
@@ -240,6 +265,17 @@ class TaskGather extends SttTask {
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
this.playTask.span = span;
|
||||
this.playTask.ctx = ctx;
|
||||
this.playTask.exec(cs, {ep}) // kicked off, _not_ waiting for it to complete
|
||||
.catch((err) => {
|
||||
process();
|
||||
});
|
||||
this.playTask.on('playDone', (err) => {
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:exec Error playing url');
|
||||
process();
|
||||
});
|
||||
}
|
||||
else {
|
||||
@@ -252,16 +288,23 @@ class TaskGather extends SttTask {
|
||||
|
||||
if (this.input.includes('speech') && this.listenDuringPrompt) {
|
||||
await this._setSpeechHandlers(cs, ep);
|
||||
this._startTranscribing(ep);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
if (!this.resolved && !this.killed) {
|
||||
this._startTranscribing(ep);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
}
|
||||
else {
|
||||
this.logger.info('Gather:exec - task was killed or resolved quickly, not starting transcription');
|
||||
}
|
||||
}
|
||||
|
||||
if (this.input.includes('digits') || this.dtmfBargein || this.asrDtmfTerminationDigit) {
|
||||
ep.on('dtmf', this._onDtmf.bind(this, cs, ep));
|
||||
// https://github.com/jambonz/jambonz-feature-server/issues/913
|
||||
if (this.listenDuringPrompt || (!this.sayTask && !this.playTask)) {
|
||||
startDtmfListener();
|
||||
}
|
||||
|
||||
await this.awaitTaskDone();
|
||||
this._killAudio(cs);
|
||||
} catch (err) {
|
||||
this.logger.error(err, 'TaskGather:exec error');
|
||||
}
|
||||
@@ -271,13 +314,13 @@ class TaskGather extends SttTask {
|
||||
kill(cs) {
|
||||
super.kill(cs);
|
||||
this._killAudio(cs);
|
||||
this._killActionHookDelayAction();
|
||||
this._clearFillerNoiseTimer();
|
||||
this.ep.removeAllListeners('dtmf');
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearAsrTimer();
|
||||
this.playTask?.span.end();
|
||||
this.sayTask?.span.end();
|
||||
this._stopVad();
|
||||
this._resolve('killed');
|
||||
}
|
||||
|
||||
@@ -297,6 +340,9 @@ class TaskGather extends SttTask {
|
||||
clearTimeout(this.interDigitTimer);
|
||||
let resolved = false;
|
||||
if (this.dtmfBargein) {
|
||||
if (!this.playComplete) {
|
||||
this.notifyStatus({event: 'dtmf-bargein-detected', ...evt});
|
||||
}
|
||||
this._killAudio(cs);
|
||||
this.emit('dtmf', evt);
|
||||
}
|
||||
@@ -355,23 +401,19 @@ class TaskGather extends SttTask {
|
||||
ep, GoogleTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, GoogleTranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, GoogleTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'aws':
|
||||
case 'polly':
|
||||
this.bugname = `${this.bugname_prefix}aws_transcribe`;
|
||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, AwsTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
break;
|
||||
case 'microsoft':
|
||||
this.bugname = `${this.bugname_prefix}azure_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, AzureTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
||||
this._onNoSpeechDetected.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.VadDetected, this._onVadDetected.bind(this, cs, ep));
|
||||
//this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
||||
//this._onNoSpeechDetected.bind(this, cs, ep));
|
||||
break;
|
||||
case 'nuance':
|
||||
this.bugname = `${this.bugname_prefix}nuance_transcribe`;
|
||||
@@ -381,8 +423,6 @@ class TaskGather extends SttTask {
|
||||
this._onStartOfSpeech.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.TranscriptionComplete,
|
||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, NuanceTranscriptionEvents.VadDetected,
|
||||
this._onVadDetected.bind(this, cs, ep));
|
||||
|
||||
/* stall timers until prompt finishes playing */
|
||||
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
|
||||
@@ -405,6 +445,12 @@ class TaskGather extends SttTask {
|
||||
ep, SonioxTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'verbio':
|
||||
this.bugname = `${this.bugname_prefix}verbio_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, VerbioTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'cobalt':
|
||||
this.bugname = `${this.bugname_prefix}cobalt_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
@@ -452,8 +498,6 @@ class TaskGather extends SttTask {
|
||||
this._onStartOfSpeech.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.TranscriptionComplete,
|
||||
this._onTranscriptionComplete.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, NvidiaTranscriptionEvents.VadDetected,
|
||||
this._onVadDetected.bind(this, cs, ep));
|
||||
|
||||
/* I think nvidia has this (??) - stall timers until prompt finishes playing */
|
||||
if ((this.sayTask || this.playTask) && this.listenDuringPrompt) {
|
||||
@@ -471,6 +515,24 @@ class TaskGather extends SttTask {
|
||||
this.addCustomEventListener(ep, AssemblyAiTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'speechmatics':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, SpeechmaticsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Info,
|
||||
this._onSpeechmaticsInfo.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.RecognitionStarted,
|
||||
this._onSpeechmaticsRecognitionStarted.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Error,
|
||||
this._onSpeechmaticsErrror.bind(this, cs, ep));
|
||||
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
||||
@@ -520,7 +582,8 @@ class TaskGather extends SttTask {
|
||||
account_sid: this.cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
vendor: this.vendor,
|
||||
detail: err.message
|
||||
detail: err.message,
|
||||
target_sid: this.cs.callSid
|
||||
});
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
}
|
||||
@@ -559,7 +622,10 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_clearAsrTimer() {
|
||||
if (this._asrTimer) clearTimeout(this._asrTimer);
|
||||
if (this._asrTimer) {
|
||||
this.logger.debug('_clearAsrTimer: asrTimer cleared');
|
||||
clearTimeout(this._asrTimer);
|
||||
}
|
||||
this._asrTimer = null;
|
||||
}
|
||||
|
||||
@@ -568,99 +634,6 @@ class TaskGather extends SttTask {
|
||||
this.cs.hangup();
|
||||
}
|
||||
|
||||
_actionHookDelaySayAction(verb) {
|
||||
delete verb.verb;
|
||||
this.logger.debug(`_actionHookDelaySayAction ${verb}`);
|
||||
this._actionHookDelaySayTask = makeTask(this.logger, {say: verb}, this);
|
||||
const {span, ctx} = this.startChildSpan(`actionHookDelayAction:${this._actionHookDelaySayTask.summary}`);
|
||||
this._actionHookDelaySayTask.span = span;
|
||||
this._actionHookDelaySayTask.ctx = ctx;
|
||||
this._actionHookDelaySayTask.exec(this.cs, {ep: this.ep});
|
||||
this._actionHookDelaySayTask.on('playDone', (err) => {
|
||||
this._actionHookDelaySayTask = null;
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:actionHookDelay Error playing tts');
|
||||
});
|
||||
}
|
||||
|
||||
_killActionHookDelayAction() {
|
||||
this.logger.debug('_killActionHookDelayAction');
|
||||
if (this._actionHookDelaySayTask && !this._actionHookDelaySayTask.killed) {
|
||||
this._actionHookDelaySayTask.removeAllListeners('playDone');
|
||||
this._actionHookDelaySayTask.kill(this.cs);
|
||||
this._actionHookDelaySayTask.span.end();
|
||||
this._actionHookDelaySayTask = null;
|
||||
}
|
||||
|
||||
if (this._actionHookDelayPlayTask && !this._actionHookDelayPlayTask.killed) {
|
||||
this._actionHookDelayPlayTask.removeAllListeners('playDone');
|
||||
this._actionHookDelayPlayTask.kill(this.cs);
|
||||
this._actionHookDelayPlayTask.span.end();
|
||||
this._actionHookDelayPlayTask = null;
|
||||
}
|
||||
}
|
||||
|
||||
_actionHookDelayPlayAction(verb) {
|
||||
delete verb.verb;
|
||||
this.logger.debug(`_actionHookDelayPlayAction ${verb}`);
|
||||
this._actionHookDelayPlayTask = makeTask(this.logger, {play: verb}, this);
|
||||
const {span, ctx} = this.startChildSpan(`actionHookDelayAction:${this._actionHookDelayPlayTask.summary}`);
|
||||
this._actionHookDelayPlayTask.span = span;
|
||||
this._actionHookDelayPlayTask.ctx = ctx;
|
||||
this._actionHookDelayPlayTask.exec(this.cs, {ep: this.ep});
|
||||
this._actionHookDelayPlayTask.on('playDone', (err) => {
|
||||
this._actionHookDelayPlayTask = null;
|
||||
span.end();
|
||||
if (err) this.logger.error({err}, 'Gather:actionHookDelay Error playing tts');
|
||||
});
|
||||
|
||||
}
|
||||
|
||||
_startActionHookNoResponseTimer() {
|
||||
assert(this._hookNoResponseTimeout > 0);
|
||||
this._clearActionHookNoResponseTimer();
|
||||
this.logger.debug('startActionHookNoResponseTimer');
|
||||
this._actionHookNoResponseTimer = setTimeout(() => {
|
||||
if (this._hookDelayRetryCount >= this._hookDelayRetries) {
|
||||
this._hangupCall();
|
||||
return;
|
||||
}
|
||||
const verb = this._hookDelayActions[this._hookDelayRetryCount % this._hookDelayActions.length];
|
||||
if (verb.verb === 'say') {
|
||||
this._actionHookDelaySayAction(verb);
|
||||
} else if (verb.verb === 'play') {
|
||||
this._actionHookDelayPlayAction(verb);
|
||||
}
|
||||
this._hookDelayRetryCount++;
|
||||
this._startActionHookNoResponseTimer();
|
||||
|
||||
}, this._hookNoResponseTimeout);
|
||||
|
||||
}
|
||||
|
||||
_clearActionHookNoResponseTimer() {
|
||||
if (this._actionHookNoResponseTimer) {
|
||||
clearTimeout(this._actionHookNoResponseTimer);
|
||||
}
|
||||
this._actionHookNoResponseTimer = null;
|
||||
}
|
||||
|
||||
_startActionHookNoResponseGiveUpTimer() {
|
||||
assert(this._hookNoResponseGiveUpTimeout > 0);
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
this.logger.debug('startActionHookNoResponseGiveUpTimer');
|
||||
this._actionHookNoResponseGiveUpTimer = setTimeout(() => {
|
||||
this._hangupCall();
|
||||
}, this._hookNoResponseGiveUpTimeout);
|
||||
}
|
||||
|
||||
_clearActionHookNoResponseGiveUpTimer() {
|
||||
if (this._actionHookNoResponseGiveUpTimer) {
|
||||
clearTimeout(this._actionHookNoResponseGiveUpTimer);
|
||||
}
|
||||
this._actionHookNoResponseGiveUpTimer = null;
|
||||
}
|
||||
|
||||
_startFastRecognitionTimer(evt) {
|
||||
assert(this.fastRecognitionTimeout > 0);
|
||||
this._clearFastRecognitionTimer();
|
||||
@@ -692,11 +665,44 @@ class TaskGather extends SttTask {
|
||||
this._finalAsrTimer = null;
|
||||
}
|
||||
|
||||
|
||||
_startVad() {
|
||||
if (!this._vadStarted && this._vadEnabled) {
|
||||
this.logger.debug('_startVad');
|
||||
this.addCustomEventListener(this.ep, VadDetection.Detection, this._onVadDetected.bind(this, this.cs, this.ep));
|
||||
this.ep?.startVadDetection(this.vad);
|
||||
this._vadStarted = true;
|
||||
}
|
||||
}
|
||||
|
||||
_stopVad() {
|
||||
if (this._vadStarted) {
|
||||
this.logger.debug('_stopVad');
|
||||
this.ep?.stopVadDetection(this.vad);
|
||||
this.ep?.removeCustomEventListener(VadDetection.Detection, this._onVadDetected);
|
||||
this._vadStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
_startFillerNoise() {
|
||||
this.logger.debug('Gather:_startFillerNoise - playing filler noise');
|
||||
this.ep?.play(this.fillerNoise.url);
|
||||
this._fillerNoiseOn = true;
|
||||
this.ep.once('playback-start', (evt) => {
|
||||
if (evt.file === this.fillerNoise.url && !this._fillerNoiseOn) {
|
||||
this.logger.info({evt}, 'Gather:_startFillerNoise - race condition - kill filler noise here');
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing filler noise'));
|
||||
return;
|
||||
} else this.logger.debug({evt}, 'Gather:_startFillerNoise - playback started');
|
||||
});
|
||||
}
|
||||
|
||||
_startFillerNoiseTimer() {
|
||||
this._clearFillerNoiseTimer();
|
||||
this._fillerNoiseTimer = setTimeout(() => {
|
||||
this.logger.debug('Gather:_startFillerNoiseTimer - playing filler noise');
|
||||
this.ep?.play(this.fillerNoise.url);
|
||||
this._startFillerNoise();
|
||||
}, this.fillerNoise.startDelaySecs * 1000);
|
||||
}
|
||||
|
||||
@@ -717,6 +723,7 @@ class TaskGather extends SttTask {
|
||||
if (this.ep?.connected && (!this.playComplete || this.hasFillerNoise)) {
|
||||
this.logger.debug('Gather:_killAudio: killing playback of any audio');
|
||||
this.playComplete = true;
|
||||
this._fillerNoiseOn = false; // in a race, if we just started audio it may sneak through here
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
}
|
||||
@@ -732,6 +739,7 @@ class TaskGather extends SttTask {
|
||||
this.playTask.kill(cs);
|
||||
this.playTask = null;
|
||||
}
|
||||
this.playComplete = true;
|
||||
}
|
||||
|
||||
_onTranscription(cs, ep, evt, fsEvent) {
|
||||
@@ -740,6 +748,7 @@ class TaskGather extends SttTask {
|
||||
const finished = fsEvent.getHeader('transcription-session-finished');
|
||||
this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription raw transcript');
|
||||
if (bugname && this.bugname !== bugname) return;
|
||||
if (finished === 'true') return;
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
@@ -762,12 +771,22 @@ class TaskGather extends SttTask {
|
||||
|
||||
evt = this.normalizeTranscription(evt, this.vendor, 1, this.language,
|
||||
this.shortUtterance, this.data.recognizer.punctuation);
|
||||
//this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription normalized transcript');
|
||||
this.logger.debug({evt, bugname, finished, vendor: this.vendor}, 'Gather:_onTranscription normalized transcript');
|
||||
|
||||
if (evt.alternatives.length === 0) {
|
||||
this.logger.info({evt}, 'TaskGather:_onTranscription - got empty transcript, continue listening');
|
||||
return;
|
||||
}
|
||||
const confidence = evt.alternatives[0].confidence;
|
||||
const minConfidence = this.data.recognizer?.minConfidence;
|
||||
this.logger.debug({evt},
|
||||
`TaskGather:_onTranscription - confidence (${confidence}), minConfidence (${minConfidence})`);
|
||||
if (confidence && minConfidence && confidence < minConfidence) {
|
||||
this.logger.info({evt},
|
||||
'TaskGather:_onTranscription - Transcript confidence ' +
|
||||
`(${confidence}) < minConfidence (${minConfidence})`);
|
||||
return this._resolve('stt-low-confidence', evt);
|
||||
}
|
||||
|
||||
/* fast path: our first partial transcript exactly matches an early hint */
|
||||
if (this.earlyHintsMatch && evt.is_final === false && this.partialTranscriptsCount++ === 0) {
|
||||
@@ -834,33 +853,36 @@ class TaskGather extends SttTask {
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram'].includes(this.vendor)) this._startTranscribing(ep);
|
||||
}
|
||||
else {
|
||||
/* this was removed to fix https://github.com/jambonz/jambonz-feature-server/issues/783 */
|
||||
/*
|
||||
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;
|
||||
}
|
||||
else {
|
||||
if (this.vendor === 'soniox') {
|
||||
/* compile transcripts into one */
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
|
||||
this._sonioxTranscripts = [];
|
||||
}
|
||||
else if (this.vendor === 'deepgram') {
|
||||
/* compile transcripts into one */
|
||||
if (!emptyTranscript) this._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, 1, this.language, this.vendor);
|
||||
this._bufferedTranscripts = [];
|
||||
}
|
||||
|
||||
/* here is where we return a final transcript */
|
||||
this._resolve('speech', evt);
|
||||
*/
|
||||
if (this.vendor === 'soniox') {
|
||||
/* compile transcripts into one */
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
evt = this.compileSonioxTranscripts(this._sonioxTranscripts, 1, this.language);
|
||||
this._sonioxTranscripts = [];
|
||||
}
|
||||
else if (this.vendor === 'deepgram') {
|
||||
/* compile transcripts into one */
|
||||
if (!emptyTranscript) this._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, 1, this.language, this.vendor);
|
||||
this._bufferedTranscripts = [];
|
||||
}
|
||||
|
||||
/* here is where we return a final transcript */
|
||||
this._resolve('speech', evt);
|
||||
/*}*/
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -881,6 +903,7 @@ class TaskGather extends SttTask {
|
||||
if (!this.playComplete) {
|
||||
this.logger.debug({transcript: evt.alternatives[0].transcript}, 'killing audio due to speech');
|
||||
this.emit('vad');
|
||||
this.notifyStatus({event: 'speech-bargein-detected', ...evt});
|
||||
}
|
||||
this._killAudio(cs);
|
||||
}
|
||||
@@ -895,12 +918,18 @@ class TaskGather extends SttTask {
|
||||
this.cs.callInfo, httpHeaders));
|
||||
}
|
||||
if (this.vendor === 'soniox') {
|
||||
this._clearTimer();
|
||||
if (evt.vendor.finalWords.length) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onTranscription - buffering soniox transcript');
|
||||
this._sonioxTranscripts.push(evt.vendor.finalWords);
|
||||
}
|
||||
}
|
||||
// If transcription received, reset timeout timer.
|
||||
if (this._timeoutTimer && !emptyTranscript) {
|
||||
this._startTimer();
|
||||
}
|
||||
/* restart asr timer if we get a partial transcript (only if the asr timer is already running) */
|
||||
/* note: https://github.com/jambonz/jambonz-feature-server/issues/866 */
|
||||
if (this.isContinuousAsr && this._asrTimer) this._startAsrTimer();
|
||||
}
|
||||
}
|
||||
_onEndOfUtterance(cs, ep) {
|
||||
@@ -932,9 +961,9 @@ class TaskGather extends SttTask {
|
||||
_onTranscriptionComplete(cs, ep) {
|
||||
this.logger.debug('TaskGather:_onTranscriptionComplete');
|
||||
}
|
||||
async _onJambonzError(cs, ep, evt) {
|
||||
this.logger.info({evt}, 'TaskGather:_onJambonzError');
|
||||
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
|
||||
|
||||
async _startFallback(cs, ep, evt) {
|
||||
if (this.canFallback) {
|
||||
ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
@@ -942,40 +971,79 @@ class TaskGather extends SttTask {
|
||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
try {
|
||||
await this._fallback();
|
||||
await this._initSpeech(cs, ep);
|
||||
this.logger.debug('gather:_startFallback');
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'in progress'});
|
||||
await this._initFallback();
|
||||
this._speechHandlersSet = false;
|
||||
await this._setSpeechHandlers(cs, ep);
|
||||
this._startTranscribing(ep);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
||||
return;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'not available'});
|
||||
}
|
||||
} else {
|
||||
this.logger.debug('gather:_startFallback no condition for falling back');
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'not available'});
|
||||
}
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
return false;
|
||||
}
|
||||
|
||||
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');
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
if (this.vendor === 'nuance') {
|
||||
const {code, error} = evt;
|
||||
if (code === 404 && error === 'No speech') return this._resolve('timeout');
|
||||
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskGather:_onJambonzError');
|
||||
const errMessage = evt.error || evt.Message;
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${errMessage}`,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
|
||||
if (!(await this._startFallback(cs, ep, evt))) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_onVendorConnectFailure(cs, _ep, evt) {
|
||||
async _onVendorConnectFailure(cs, _ep, evt) {
|
||||
super._onVendorConnectFailure(cs, _ep, evt);
|
||||
this.notifyTaskDone();
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_onVendorError(cs, _ep, evt) {
|
||||
async _onSpeechmaticsErrror(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onVendorError(cs, _ep, evt) {
|
||||
super._onVendorError(cs, _ep, evt);
|
||||
this._resolve('stt-error', evt);
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
this._resolve('stt-error', evt);
|
||||
}
|
||||
}
|
||||
async _onSpeechmaticsRecognitionStarted(_cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onSpeechmaticsRecognitionStarted');
|
||||
}
|
||||
|
||||
async _onSpeechmaticsInfo(_cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onSpeechmaticsInfo');
|
||||
}
|
||||
|
||||
_onVadDetected(cs, ep) {
|
||||
@@ -984,6 +1052,10 @@ class TaskGather extends SttTask {
|
||||
this._killAudio(cs);
|
||||
this.emit('vad');
|
||||
}
|
||||
if (this.vad?.strategy === 'one-shot') {
|
||||
this.ep?.removeCustomEventListener(VadDetection.Detection, this._onVadDetected);
|
||||
this._vadStarted = false;
|
||||
}
|
||||
}
|
||||
|
||||
_onNoSpeechDetected(cs, ep, evt, fsEvent) {
|
||||
@@ -1002,7 +1074,20 @@ class TaskGather extends SttTask {
|
||||
|
||||
async _resolve(reason, evt) {
|
||||
this.logger.debug(`TaskGather:resolve with reason ${reason}`);
|
||||
if (this.resolved) return;
|
||||
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) {
|
||||
this.logger.debug('TaskGather:_resolve - already resolved');
|
||||
return;
|
||||
}
|
||||
|
||||
this.resolved = true;
|
||||
// If bargin is false and ws application return ack to verb:hook
|
||||
@@ -1013,19 +1098,15 @@ class TaskGather extends SttTask {
|
||||
clearTimeout(this.interDigitTimer);
|
||||
this._clearTimer();
|
||||
this._clearFastRecognitionTimer();
|
||||
this._clearAsrTimer();
|
||||
this._clearFinalAsrTimer();
|
||||
|
||||
this.span.setAttributes({
|
||||
channel: 1,
|
||||
'stt.label': this.label || 'None',
|
||||
'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');
|
||||
@@ -1033,59 +1114,80 @@ class TaskGather extends SttTask {
|
||||
return;
|
||||
}
|
||||
|
||||
// Enabled action Hook delay timer to applied actions
|
||||
if (this._hookNoResponseTimeout > 0) {
|
||||
this._startActionHookNoResponseTimer();
|
||||
}
|
||||
|
||||
if (this._hookNoResponseGiveUpTimeout > 0) {
|
||||
this._startActionHookNoResponseGiveUpTimer();
|
||||
// action hook delay
|
||||
if (this.cs.actionHookDelayProcessor) {
|
||||
this.logger.debug('TaskGather:_resolve - actionHookDelayProcessor exists - starting it');
|
||||
this.cs.actionHookDelayProcessor.start();
|
||||
}
|
||||
|
||||
// TODO: remove and implement as actionHookDelay
|
||||
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);
|
||||
this._startFillerNoise();
|
||||
}
|
||||
}
|
||||
|
||||
let returnedVerbs = false;
|
||||
try {
|
||||
if (reason.startsWith('dtmf')) {
|
||||
if (this.parentTask) this.parentTask.emit('dtmf', evt);
|
||||
else {
|
||||
this.emit('dtmf', evt);
|
||||
await this.performAction({digits: this.digitBuffer, reason: 'dtmfDetected'});
|
||||
returnedVerbs = await this.performAction({digits: this.digitBuffer, reason: 'dtmfDetected'});
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('speech')) {
|
||||
if (this.parentTask) this.parentTask.emit('transcription', evt);
|
||||
else {
|
||||
this.emit('transcription', evt);
|
||||
await this.performAction({speech: evt, reason: 'speechDetected'});
|
||||
this.logger.debug('TaskGather:_resolve - invoking performAction');
|
||||
returnedVerbs = await this.performAction({speech: evt, reason: 'speechDetected'});
|
||||
this.logger.debug({returnedVerbs}, 'TaskGather:_resolve - back from performAction');
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('timeout')) {
|
||||
if (this.parentTask) this.parentTask.emit('timeout', evt);
|
||||
else {
|
||||
this.emit('timeout', evt);
|
||||
await this.performAction({reason: 'timeout'});
|
||||
returnedVerbs = await this.performAction({reason: 'timeout'});
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('stt-error')) {
|
||||
if (this.parentTask) this.parentTask.emit('stt-error', evt);
|
||||
else {
|
||||
this.emit('stt-error', evt);
|
||||
await this.performAction({reason: 'error', details: evt.error});
|
||||
returnedVerbs = await this.performAction({reason: 'error', details: evt.error});
|
||||
}
|
||||
} else if (reason.startsWith('stt-low-confidence')) {
|
||||
if (this.parentTask) this.parentTask.emit('stt-low-confidence', evt);
|
||||
else {
|
||||
this.emit('stt-low-confidence', evt);
|
||||
returnedVerbs = await this.performAction({reason: 'stt-low-confidence'});
|
||||
}
|
||||
}
|
||||
} catch (err) { /*already logged error*/ }
|
||||
|
||||
// Gather got response from hook, cancel all delay timers if there is any
|
||||
this._clearActionHookNoResponseTimer();
|
||||
this._clearActionHookNoResponseGiveUpTimer();
|
||||
// Gather got response from hook, cancel actionHookDelay processing
|
||||
this.logger.debug('TaskGather:_resolve - checking ahd');
|
||||
if (this.cs.actionHookDelayProcessor) {
|
||||
if (returnedVerbs) {
|
||||
this.logger.debug('TaskGather:_resolve - got response from action hook, cancelling actionHookDelay');
|
||||
await this.cs.actionHookDelayProcessor.stop();
|
||||
if (this.actionHookDelayAction && !this.cs.popActionHookDelayProperties()) {
|
||||
// no session level ahd was running when this task started, so clear it
|
||||
this.cs.clearActionHookDelayProcessor();
|
||||
this.logger.debug('TaskGather:_resolve - clear ahd');
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug('TaskGather:_resolve - no response from action hook, continue actionHookDelay');
|
||||
}
|
||||
}
|
||||
|
||||
this._clearFillerNoiseTimer();
|
||||
|
||||
this.notifyTaskDone();
|
||||
|
||||
96
lib/tasks/llm/index.js
Normal file
96
lib/tasks/llm/index.js
Normal file
@@ -0,0 +1,96 @@
|
||||
const Task = require('../task');
|
||||
const {TaskPreconditions} = require('../../utils/constants');
|
||||
const TaskLlmOpenAI_S2S = require('./llms/openai_s2s');
|
||||
|
||||
class TaskLlm extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
['vendor', 'model', 'auth', 'connectOptions'].forEach((prop) => {
|
||||
this[prop] = this.data[prop];
|
||||
});
|
||||
|
||||
this.eventHandlers = [];
|
||||
|
||||
// delegate to the specific llm model
|
||||
this.llm = this.createSpecificLlm();
|
||||
}
|
||||
|
||||
get name() { return this.llm.name ; }
|
||||
|
||||
get toolHook() { return this.llm?.toolHook; }
|
||||
|
||||
get eventHook() { return this.llm?.eventHook; }
|
||||
|
||||
get ep() { return this.cs.ep; }
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs, {ep});
|
||||
await this.llm.exec(cs, {ep});
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
await this.llm.kill(cs);
|
||||
}
|
||||
|
||||
createSpecificLlm() {
|
||||
let llm;
|
||||
switch (this.vendor) {
|
||||
case 'openai':
|
||||
case 'microsoft':
|
||||
if (this.model.startsWith('gpt-4o-realtime')) {
|
||||
llm = new TaskLlmOpenAI_S2S(this.logger, this.data, this);
|
||||
}
|
||||
break;
|
||||
default:
|
||||
throw new Error(`Unsupported vendor ${this.vendor} for LLM`);
|
||||
}
|
||||
|
||||
if (!llm) {
|
||||
throw new Error(`Unsupported vendor:model ${this.vendor}:${this.model}`);
|
||||
}
|
||||
return llm;
|
||||
}
|
||||
|
||||
addCustomEventListener(ep, event, handler) {
|
||||
this.eventHandlers.push({ep, event, handler});
|
||||
ep.addCustomEventListener(event, handler);
|
||||
}
|
||||
|
||||
removeCustomEventListeners() {
|
||||
this.eventHandlers.forEach((h) => h.ep.removeCustomEventListener(h.event, h.handler));
|
||||
}
|
||||
|
||||
async sendEventHook(data) {
|
||||
await this.cs?.requestor.request('llm:event', this.eventHook, data);
|
||||
}
|
||||
|
||||
async sendToolHook(tool_call_id, data) {
|
||||
await this.cs?.requestor.request('llm:tool-call', this.toolHook, {tool_call_id, ...data});
|
||||
}
|
||||
|
||||
async processToolOutput(tool_call_id, data) {
|
||||
if (!this.ep.connected) {
|
||||
this.logger.info('TaskLlm:processToolOutput - no connected endpoint');
|
||||
return;
|
||||
}
|
||||
this.llm.processToolOutput(this.ep, tool_call_id, data);
|
||||
}
|
||||
|
||||
async processLlmUpdate(data, callSid) {
|
||||
if (this.ep.connected) {
|
||||
if (typeof this.llm.processLlmUpdate === 'function') {
|
||||
this.llm.processLlmUpdate(this.ep, data, callSid);
|
||||
}
|
||||
else {
|
||||
const {vendor, model} = this.llm;
|
||||
this.logger.info({data, callSid},
|
||||
`TaskLlm:_processLlmUpdate: LLM ${vendor}:${model} does not support llm:update`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlm;
|
||||
357
lib/tasks/llm/llms/openai_s2s.js
Normal file
357
lib/tasks/llm/llms/openai_s2s.js
Normal file
@@ -0,0 +1,357 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_OpenAI_s2s';
|
||||
const {LlmEvents_OpenAI} = require('../../../utils/constants');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const openai_server_events = [
|
||||
'error',
|
||||
'session.created',
|
||||
'session.updated',
|
||||
'conversation.created',
|
||||
'input_audio_buffer.committed',
|
||||
'input_audio_buffer.cleared',
|
||||
'input_audio_buffer.speech_started',
|
||||
'input_audio_buffer.speech_stopped',
|
||||
'conversation.item.created',
|
||||
'conversation.item.input_audio_transcription.completed',
|
||||
'conversation.item.input_audio_transcription.failed',
|
||||
'conversation.item.truncated',
|
||||
'conversation.item.deleted',
|
||||
'response.created',
|
||||
'response.done',
|
||||
'response.output_item.added',
|
||||
'response.output_item.done',
|
||||
'response.content_part.added',
|
||||
'response.content_part.done',
|
||||
'response.text.delta',
|
||||
'response.text.done',
|
||||
'response.audio_transcript.delta',
|
||||
'response.audio_transcript.done',
|
||||
'response.audio.delta',
|
||||
'response.audio.done',
|
||||
'response.function_call_arguments.delta',
|
||||
'response.function_call_arguments.done',
|
||||
'rate_limits.updated',
|
||||
'output_audio.playback_started',
|
||||
'output_audio.playback_stopped',
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
const expandedEvents = [];
|
||||
|
||||
events.forEach((evt) => {
|
||||
if (evt.endsWith('.*')) {
|
||||
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
|
||||
const matchingEvents = openai_server_events.filter((e) => e.startsWith(prefix));
|
||||
expandedEvents.push(...matchingEvents);
|
||||
} else {
|
||||
expandedEvents.push(evt);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedEvents;
|
||||
};
|
||||
|
||||
class TaskLlmOpenAI_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model;
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for OpenAI S2S');
|
||||
|
||||
if (['openai', 'microsoft'].indexOf(this.vendor) === -1) {
|
||||
throw new Error(`Invalid vendor ${this.vendor} for OpenAI S2S`);
|
||||
}
|
||||
|
||||
if ('microsoft' === this.vendor && !this.connectionOptions?.host) {
|
||||
throw new Error('connectionOptions.host is required for Microsoft OpenAI S2S');
|
||||
}
|
||||
|
||||
this.apiKey = apiKey;
|
||||
this.authType = 'microsoft' === this.vendor ? 'query' : 'bearer';
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {response_create, session_update} = this.data.llmOptions;
|
||||
|
||||
if (typeof response_create !== 'object') {
|
||||
throw new Error('llmOptions with an initial response.create is required for OpenAI S2S');
|
||||
}
|
||||
|
||||
this.response_create = response_create;
|
||||
this.session_update = session_update;
|
||||
|
||||
this.results = {
|
||||
completionReason: 'normal conversation end'
|
||||
};
|
||||
|
||||
/**
|
||||
* only one of these will have items,
|
||||
* if includeEvents, then these are the events to include
|
||||
* if excludeEvents, then these are the events to exclude
|
||||
*/
|
||||
this.includeEvents = [];
|
||||
this.excludeEvents = [];
|
||||
|
||||
/* default to all events if user did not specify */
|
||||
this._populateEvents(this.data.events || openai_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
get host() {
|
||||
const {host} = this.connectionOptions || {};
|
||||
return host || (this.vendor === 'openai' ? 'api.openai.com' : void 0);
|
||||
}
|
||||
|
||||
get path() {
|
||||
const {path} = this.connectionOptions || {};
|
||||
if (path) return path;
|
||||
|
||||
switch (this.vendor) {
|
||||
case 'openai':
|
||||
return 'v1/realtime?model=gpt-4o-realtime-preview-2024-10-01';
|
||||
case 'microsoft':
|
||||
return 'openai/realtime?api-version=2024-10-01-preview&deployment=gpt-4o-realtime-preview-1001&';
|
||||
}
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_openai_s2s', `^^|${args.join('|')}`);
|
||||
if (!res.body?.startsWith('+OK')) {
|
||||
throw new Error({args}, `Error calling uuid_openai_s2s: ${res.body}`);
|
||||
}
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs);
|
||||
|
||||
await this._startListening(cs, ep);
|
||||
|
||||
await this.awaitTaskDone();
|
||||
|
||||
/* note: the parent llm verb started the span, which is why this is necessary */
|
||||
await this.parent.performAction(this.results);
|
||||
|
||||
this._unregisterHandlers();
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
|
||||
this._api(cs.ep, [cs.ep.uuid, SessionDelete])
|
||||
.catch((err) => this.logger.info({err}, 'TaskLlmOpenAI_S2S:kill - error deleting session'));
|
||||
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/**
|
||||
* Send function call output to the OpenAI server in the form of conversation.item.create
|
||||
* per https://platform.openai.com/docs/guides/realtime/function-calls
|
||||
*/
|
||||
async processToolOutput(ep, tool_call_id, data) {
|
||||
try {
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmOpenAI_S2S:processToolOutput');
|
||||
|
||||
if (!data.type || data.type !== 'conversation.item.create') {
|
||||
this.logger.info({data},
|
||||
'TaskLlmOpenAI_S2S:processToolOutput - invalid tool output, must be conversation.item.create');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
|
||||
// spec also recommends to send immediate response.create
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify({type: 'response.create'})]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmOpenAI_S2S:processToolOutput');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a session.update to the OpenAI server
|
||||
* Note: creating and deleting conversation items also supported as well as interrupting the assistant
|
||||
*/
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmOpenAI_S2S:processLlmUpdate');
|
||||
|
||||
if (!data.type || ![
|
||||
'session.update',
|
||||
'conversation.item.create',
|
||||
'conversation.item.delete',
|
||||
'response.cancel'
|
||||
].includes(data.type)) {
|
||||
this.logger.info({data}, 'TaskLlmOpenAI_S2S:processLlmUpdate - invalid mid-call request');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmOpenAI_S2S:processLlmUpdate');
|
||||
}
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const args = [ep.uuid, 'session.create', this.host, this.path, this.authType, this.apiKey];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmOpenAI_S2S:_startListening');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmOpenAI_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmOpenAI_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
let obj = {type: 'response.create', response: this.response_create};
|
||||
if (!await this._sendClientEvent(ep, obj)) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
/* send immediate session.update if present */
|
||||
else if (this.session_update) {
|
||||
obj = {type: 'session.update', session: this.session_update};
|
||||
this.logger.debug({obj}, 'TaskLlmOpenAI_S2S:_sendInitialMessage - sending session.update');
|
||||
if (!await this._sendClientEvent(ep, obj)) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_OpenAI.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmOpenAI_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmOpenAI_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmOpenAI_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmOpenAI_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
async _onServerEvent(ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.info({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent');
|
||||
|
||||
/* check for failures, such as rate limit exceeded, that should terminate the conversation */
|
||||
if (type === 'response.done' && evt.response.status === 'failed') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server failure',
|
||||
error: evt.response.status_details?.error
|
||||
};
|
||||
}
|
||||
|
||||
/* server errors of some sort */
|
||||
else if (type === 'error') {
|
||||
endConversation = true;
|
||||
this.results = {
|
||||
completionReason: 'server error',
|
||||
error: evt.error
|
||||
};
|
||||
}
|
||||
|
||||
/* tool calls */
|
||||
else if (type === 'response.output_item.done' && evt.item?.type === 'function_call') {
|
||||
this.logger.debug({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call');
|
||||
if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmOpenAI_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {name, call_id} = evt.item;
|
||||
const args = JSON.parse(evt.item.arguments);
|
||||
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmOpenAI - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* check whether we should notify on this event */
|
||||
if (this.includeEvents.length > 0 ? this.includeEvents.includes(type) : !this.excludeEvents.includes(type)) {
|
||||
this.parent.sendEventHook(evt)
|
||||
.catch((err) => this.logger.info({err}, 'TaskLlmOpenAI_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results}, 'TaskLlmOpenAI_S2S:_onServerEvent - ending conversation due to error');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_populateEvents(events) {
|
||||
if (events.includes('all')) {
|
||||
/* work by excluding specific events */
|
||||
const exclude = events
|
||||
.filter((evt) => evt.startsWith('-'))
|
||||
.map((evt) => evt.slice(1));
|
||||
if (exclude.length === 0) this.includeEvents = openai_server_events;
|
||||
else this.excludeEvents = expandWildcards(exclude);
|
||||
}
|
||||
else {
|
||||
/* work by including specific events */
|
||||
const include = events
|
||||
.filter((evt) => !evt.startsWith('-'));
|
||||
this.includeEvents = expandWildcards(include);
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
includeEvents: this.includeEvents,
|
||||
excludeEvents: this.excludeEvents
|
||||
}, 'TaskLlmOpenAI_S2S:_populateEvents');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmOpenAI_S2S;
|
||||
@@ -62,6 +62,9 @@ function makeTask(logger, obj, parent) {
|
||||
case TaskName.Message:
|
||||
const TaskMessage = require('./message');
|
||||
return new TaskMessage(logger, data, parent);
|
||||
case TaskName.Llm:
|
||||
const TaskLlm = require('./llm');
|
||||
return new TaskLlm(logger, data, parent);
|
||||
case TaskName.Rasa:
|
||||
const TaskRasa = require('./rasa');
|
||||
return new TaskRasa(logger, data, parent);
|
||||
|
||||
@@ -17,6 +17,7 @@ class TaskRestDial extends Task {
|
||||
this.call_hook = this.data.call_hook;
|
||||
this.timeout = this.data.timeout || 60;
|
||||
this.sipRequestWithinDialogHook = this.data.sipRequestWithinDialogHook;
|
||||
this.referHook = this.data.referHook;
|
||||
|
||||
this.on('connect', this._onConnect.bind(this));
|
||||
this.on('callStatus', this._onCallStatus.bind(this));
|
||||
@@ -38,9 +39,9 @@ class TaskRestDial extends Task {
|
||||
|
||||
if (this.data.amd) {
|
||||
this.startAmd = cs.startAmd;
|
||||
this.stopAmd = cs.stopAmd;
|
||||
this.on('amd', this._onAmdEvent.bind(this, cs));
|
||||
}
|
||||
this.stopAmd = cs.stopAmd;
|
||||
|
||||
this._setCallTimer();
|
||||
await this.awaitTaskDone();
|
||||
@@ -64,6 +65,7 @@ class TaskRestDial extends Task {
|
||||
this.canCancel = false;
|
||||
const cs = this.callSession;
|
||||
cs.setDialog(dlg);
|
||||
cs.referHook = this.referHook;
|
||||
this.logger.debug('TaskRestDial:_onConnect - call connected');
|
||||
if (this.sipRequestWithinDialogHook) this._initSipRequestWithinDialogHandler(cs, dlg);
|
||||
try {
|
||||
|
||||
163
lib/tasks/say.js
163
lib/tasks/say.js
@@ -1,6 +1,7 @@
|
||||
const TtsTask = require('./tts-task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const pollySSMLSplit = require('polly-ssml-split');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
|
||||
const breakLengthyTextIfNeeded = (logger, text) => {
|
||||
const chunkSize = 1000;
|
||||
@@ -52,27 +53,50 @@ class TaskSay extends TtsTask {
|
||||
return `${this.name}{${this.text[0]}}`;
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
const {srf, accountSid:account_sid} = cs;
|
||||
_validateURL(urlString) {
|
||||
try {
|
||||
new URL(urlString);
|
||||
return true;
|
||||
} catch (e) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async exec(cs, obj) {
|
||||
try {
|
||||
await this.handling(cs, obj);
|
||||
this.emit('playDone');
|
||||
} catch (error) {
|
||||
if (error instanceof SpeechCredentialError) {
|
||||
// if say failed due to speech credentials, alarm is writtern and error notification is sent
|
||||
// finished this say to move to next task.
|
||||
this.logger.info('Say failed due to SpeechCredentialError, finished!');
|
||||
this.emit('playDone');
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handling(cs, {ep}) {
|
||||
const {srf, accountSid:account_sid, callSid:target_sid} = cs;
|
||||
const {writeAlerts, AlertType} = srf.locals;
|
||||
const {addFileToCache} = srf.locals.dbHelpers;
|
||||
const engine = this.synthesizer.engine || 'standard';
|
||||
const engine = this.synthesizer.engine || cs.synthesizer?.engine || 'neural';
|
||||
|
||||
await super.exec(cs);
|
||||
this.ep = ep;
|
||||
|
||||
const vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
let vendor = this.synthesizer.vendor && this.synthesizer.vendor !== 'default' ?
|
||||
this.synthesizer.vendor :
|
||||
cs.speechSynthesisVendor;
|
||||
const language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
let language = this.synthesizer.language && this.synthesizer.language !== 'default' ?
|
||||
this.synthesizer.language :
|
||||
cs.speechSynthesisLanguage ;
|
||||
const voice = this.synthesizer.voice && this.synthesizer.voice !== 'default' ?
|
||||
let 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;
|
||||
let label = this.taskInlcudeSynthesizer ? this.synthesizer.label : cs.speechSynthesisLabel;
|
||||
|
||||
const fallbackVendor = this.synthesizer.fallbackVendor && this.synthesizer.fallbackVendor !== 'default' ?
|
||||
this.synthesizer.fallbackVendor :
|
||||
@@ -83,16 +107,22 @@ class TaskSay extends TtsTask {
|
||||
const fallbackVoice = this.synthesizer.fallbackVoice && this.synthesizer.fallbackVoice !== 'default' ?
|
||||
this.synthesizer.fallbackVoice :
|
||||
cs.fallbackSpeechSynthesisVoice;
|
||||
const fallbackLabel = this.synthesizer.fallbackLabel && this.synthesizer.fallbackLabel !== 'default' ?
|
||||
this.synthesizer.fallbackLabel :
|
||||
cs.fallbackSpeechSynthesisLabel;
|
||||
const fallbackLabel = this.taskInlcudeSynthesizer ?
|
||||
this.synthesizer.fallbackLabel : cs.fallbackSpeechSynthesisLabel;
|
||||
|
||||
let filepath;
|
||||
try {
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
||||
} catch (error) {
|
||||
if (fallbackVendor && this.isHandledByPrimaryProvider) {
|
||||
if (cs.hasFallbackTts) {
|
||||
vendor = fallbackVendor;
|
||||
language = fallbackLanguage;
|
||||
voice = fallbackVoice;
|
||||
label = fallbackLabel;
|
||||
}
|
||||
|
||||
const startFallback = async(error) => {
|
||||
if (fallbackVendor && this.isHandledByPrimaryProvider && !cs.hasFallbackTts) {
|
||||
this.notifyError(
|
||||
{ msg: 'TTS error', details:`TTS vendor ${vendor} error: ${error}`, failover: 'in progress'});
|
||||
this.isHandledByPrimaryProvider = false;
|
||||
cs.hasFallbackTts = true;
|
||||
this.logger.info(`Synthesize error, fallback to ${fallbackVendor}`);
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep,
|
||||
{
|
||||
@@ -102,8 +132,16 @@ class TaskSay extends TtsTask {
|
||||
label: fallbackLabel
|
||||
});
|
||||
} else {
|
||||
throw error;
|
||||
this.notifyError(
|
||||
{ msg: 'TTS error', details:`TTS vendor ${vendor} error: ${error}`, failover: 'not available'});
|
||||
throw new SpeechCredentialError(error.message);
|
||||
}
|
||||
};
|
||||
let filepath;
|
||||
try {
|
||||
filepath = await this._synthesizeWithSpecificVendor(cs, ep, {vendor, language, voice, label});
|
||||
} catch (error) {
|
||||
await startFallback(error);
|
||||
}
|
||||
this.notifyStatus({event: 'start-playback'});
|
||||
|
||||
@@ -115,31 +153,35 @@ class TaskSay extends TtsTask {
|
||||
await this.playToConfMember(ep, memberId, confName, confUuid, filepath[segment]);
|
||||
}
|
||||
else {
|
||||
if (filepath[segment].startsWith('say:{')) {
|
||||
const isStreaming = filepath[segment].startsWith('say:{');
|
||||
if (isStreaming) {
|
||||
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
||||
if (arr) this.logger.debug(`Say:exec sending streaming tts request: ${arr[1].substring(0, 64)}..`);
|
||||
}
|
||||
else this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
|
||||
else this.logger.debug(`Say:exec sending ${filepath[segment].substring(0, 64)}`);
|
||||
ep.once('playback-start', (evt) => {
|
||||
this.logger.debug({evt}, 'got playback-start');
|
||||
this.logger.debug({evt}, 'Say got playback-start');
|
||||
if (this.otelSpan) {
|
||||
this._addStreamingTtsAttributes(this.otelSpan, evt);
|
||||
this.otelSpan.end();
|
||||
this.otelSpan = null;
|
||||
if (evt.variable_tts_cache_filename) cs.trackTmpFile(evt.variable_tts_cache_filename);
|
||||
if (evt.variable_tts_cache_filename) {
|
||||
cs.trackTmpFile(evt.variable_tts_cache_filename);
|
||||
}
|
||||
}
|
||||
});
|
||||
ep.once('playback-stop', (evt) => {
|
||||
this.logger.debug({evt}, 'got playback-stop');
|
||||
this.logger.debug({evt}, 'Say got playback-stop');
|
||||
if (evt.variable_tts_error) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: evt.variable_tts_error
|
||||
detail: evt.variable_tts_error,
|
||||
target_sid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
}
|
||||
if (evt.variable_tts_cache_filename) {
|
||||
if (evt.variable_tts_cache_filename && !this.killed) {
|
||||
const text = parseTextFromSayString(this.text[segment]);
|
||||
addFileToCache(evt.variable_tts_cache_filename, {
|
||||
account_sid,
|
||||
@@ -150,18 +192,45 @@ class TaskSay extends TtsTask {
|
||||
text
|
||||
}).catch((err) => this.logger.info({err}, 'Error adding file to cache'));
|
||||
}
|
||||
|
||||
if (this._playResolve) {
|
||||
evt.variable_tts_error ? this._playReject(new Error(evt.variable_tts_error)) : this._playResolve();
|
||||
}
|
||||
});
|
||||
await ep.play(filepath[segment]);
|
||||
// wait for playback-stop event received to confirm if the playback is successful
|
||||
this._playPromise = new Promise((resolve, reject) => {
|
||||
this._playResolve = resolve;
|
||||
this._playReject = reject;
|
||||
});
|
||||
const r = await ep.play(filepath[segment]);
|
||||
this.logger.debug({r}, 'Say:exec play result');
|
||||
try {
|
||||
// wait for playback-stop event received to confirm if the playback is successful
|
||||
await this._playPromise;
|
||||
} catch (err) {
|
||||
try {
|
||||
await startFallback(err);
|
||||
continue;
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'Error waiting for playback-stop event');
|
||||
throw err;
|
||||
}
|
||||
} finally {
|
||||
this._playPromise = null;
|
||||
this._playResolve = null;
|
||||
this._playReject = null;
|
||||
}
|
||||
if (filepath[segment].startsWith('say:{')) {
|
||||
const arr = /^say:\{.*\}\s*(.*)$/.exec(filepath[segment]);
|
||||
if (arr) this.logger.debug(`Say:exec complete playing streaming tts request: ${arr[1].substring(0, 64)}..`);
|
||||
} else {
|
||||
// This log will print spech credentials in say command for tts stream mode
|
||||
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
|
||||
}
|
||||
this.logger.debug(`Say:exec completed play file ${filepath[segment]}`);
|
||||
}
|
||||
segment++;
|
||||
}
|
||||
}
|
||||
this.emit('playDone');
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
@@ -178,6 +247,11 @@ class TaskSay extends TtsTask {
|
||||
}
|
||||
this.ep.removeAllListeners('playback-start');
|
||||
this.ep.removeAllListeners('playback-stop');
|
||||
// if we are waiting for playback-stop event, resolve the promise
|
||||
if (this._playResolve) {
|
||||
this._playResolve();
|
||||
this._playResolve = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -187,6 +261,10 @@ class TaskSay extends TtsTask {
|
||||
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('verbio_', 'verbio.')
|
||||
.replace('elevenlabs_', 'elevenlabs.');
|
||||
if (spanMapping[newKey]) newKey = spanMapping[newKey];
|
||||
attrs[newKey] = value;
|
||||
@@ -198,6 +276,9 @@ class TaskSay extends TtsTask {
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -205,11 +286,37 @@ 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',
|
||||
// verbio
|
||||
'verbio.name_lookup_time_ms': 'name_lookup_ms',
|
||||
'verbio.connect_time_ms': 'connect_ms',
|
||||
'verbio.final_response_time_ms': 'final_response_ms',
|
||||
};
|
||||
|
||||
module.exports = TaskSay;
|
||||
|
||||
@@ -2,6 +2,8 @@ const Task = require('./task');
|
||||
const assert = require('assert');
|
||||
const crypto = require('crypto');
|
||||
const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const {JAMBONES_AWS_TRANSCRIBE_USE_GRPC} = require('../config');
|
||||
|
||||
class SttTask extends Task {
|
||||
|
||||
@@ -16,14 +18,22 @@ class SttTask extends Task {
|
||||
normalizeTranscription,
|
||||
setSpeechCredentialsAtRuntime,
|
||||
compileSonioxTranscripts,
|
||||
consolidateTranscripts
|
||||
consolidateTranscripts,
|
||||
updateSpeechmaticsPayload
|
||||
} = require('../utils/transcription-utils')(logger);
|
||||
this.setChannelVarsForStt = setChannelVarsForStt;
|
||||
this.normalizeTranscription = normalizeTranscription;
|
||||
this.compileSonioxTranscripts = compileSonioxTranscripts;
|
||||
this.consolidateTranscripts = consolidateTranscripts;
|
||||
this.updateSpeechmaticsPayload = updateSpeechmaticsPayload;
|
||||
this.eventHandlers = [];
|
||||
this.isHandledByPrimaryProvider = true;
|
||||
/**
|
||||
* Task use taskIncludeRecognizer to identify
|
||||
* if taskIncludeRecognizer === true, use label from verb.recognizer, even it's empty
|
||||
* if taskIncludeRecognizer === false, use label from application.recognizer
|
||||
*/
|
||||
this.taskIncludeRecognizer = !!this.data.recognizer;
|
||||
if (this.data.recognizer) {
|
||||
const recognizer = this.data.recognizer;
|
||||
this.vendor = recognizer.vendor;
|
||||
@@ -33,7 +43,7 @@ class SttTask extends Task {
|
||||
//fallback
|
||||
this.fallbackVendor = recognizer.fallbackVendor || 'default';
|
||||
this.fallbackLanguage = recognizer.fallbackLanguage || 'default';
|
||||
this.fallbackLabel = recognizer.fallbackLabel || 'default';
|
||||
this.fallbackLabel = recognizer.fallbackLabel;
|
||||
|
||||
/* let credentials be supplied in the recognizer object at runtime */
|
||||
this.sttCredentials = setSpeechCredentialsAtRuntime(recognizer);
|
||||
@@ -81,7 +91,7 @@ class SttTask extends Task {
|
||||
this.language = cs.speechRecognizerLanguage;
|
||||
if (this.data.recognizer) this.data.recognizer.language = this.language;
|
||||
}
|
||||
if ('default' === this.label || !this.label) {
|
||||
if (!this.taskIncludeRecognizer) {
|
||||
this.label = cs.speechRecognizerLabel;
|
||||
if (this.data.recognizer) this.data.recognizer.label = this.label;
|
||||
}
|
||||
@@ -94,10 +104,22 @@ class SttTask extends Task {
|
||||
this.fallbackLanguage = cs.fallbackSpeechRecognizerLanguage;
|
||||
if (this.data.recognizer) this.data.recognizer.fallbackLanguage = this.fallbackLanguage;
|
||||
}
|
||||
if ('default' === this.fallbackLabel || !this.fallbackLabel) {
|
||||
if (!this.taskIncludeRecognizer) {
|
||||
this.fallbackLabel = cs.fallbackSpeechRecognizerLabel;
|
||||
if (this.data.recognizer) this.data.recognizer.fallbackLabel = this.fallbackLabel;
|
||||
}
|
||||
|
||||
if (cs.hasFallbackAsr) {
|
||||
if (this.taskIncludeRecognizer) {
|
||||
// reset fallback ASR from previous run if this verb contains data.recognizer.
|
||||
cs.hasFallbackAsr = false;
|
||||
} else {
|
||||
this.logger.debug('Call session has fallback to 2nd ASR, use 2nd recognizer configuration');
|
||||
this.vendor = this.fallbackVendor;
|
||||
this.language = this.fallbackLanguage;
|
||||
this.label = this.fallbackLabel;
|
||||
}
|
||||
}
|
||||
if (!this.data.recognizer.vendor) {
|
||||
this.data.recognizer.vendor = this.vendor;
|
||||
}
|
||||
@@ -115,9 +137,19 @@ class SttTask extends Task {
|
||||
try {
|
||||
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
|
||||
} catch (error) {
|
||||
if (this.fallbackVendor && this.isHandledByPrimaryProvider) {
|
||||
await this._fallback();
|
||||
if (this.canFallback) {
|
||||
this.notifyError(
|
||||
{
|
||||
msg: 'ASR error', details:`Invalid vendor ${this.vendor}, Error: ${error}`,
|
||||
failover: 'in progress'
|
||||
});
|
||||
await this._initFallback();
|
||||
} else {
|
||||
this.notifyError(
|
||||
{
|
||||
msg: 'ASR error', details:`Invalid vendor ${this.vendor}, Error: ${error}`,
|
||||
failover: 'not available'
|
||||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
@@ -149,7 +181,7 @@ class SttTask extends Task {
|
||||
}
|
||||
|
||||
async _initSpeechCredentials(cs, vendor, label) {
|
||||
const {getNuanceAccessToken, getIbmAccessToken} = cs.srf.locals.dbHelpers;
|
||||
const {getNuanceAccessToken, getIbmAccessToken, getAwsAuthToken, getVerbioAccessToken} = cs.srf.locals.dbHelpers;
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'stt', label);
|
||||
|
||||
if (!credentials) {
|
||||
@@ -158,15 +190,11 @@ class SttTask extends Task {
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_NOT_PROVISIONED,
|
||||
vendor
|
||||
vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no stt'));
|
||||
// Notify application that STT vender is wrong.
|
||||
this.notifyError({
|
||||
msg: 'ASR error',
|
||||
details: `No speech-to-text service credentials for ${vendor} have been configured`
|
||||
});
|
||||
this.notifyTaskDone();
|
||||
throw new Error(`No speech-to-text service credentials for ${vendor} have been configured`);
|
||||
// the ASR might have fallback configuration, should not done task here.
|
||||
throw new SpeechCredentialError(`No speech-to-text service credentials for ${vendor} have been configured`);
|
||||
}
|
||||
|
||||
if (vendor === 'nuance' && credentials.client_id) {
|
||||
@@ -182,21 +210,54 @@ class SttTask extends Task {
|
||||
const {access_token, servedFromCache} = await getIbmAccessToken(stt_api_key);
|
||||
this.logger.debug({stt_api_key}, `got ibm access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials = {...credentials, access_token, stt_region};
|
||||
} else if (['aws', 'polly'].includes(vendor) && credentials.roleArn) {
|
||||
/* get aws access token */
|
||||
const {roleArn, region} = credentials;
|
||||
const {accessKeyId, secretAccessKey, sessionToken, servedFromCache} =
|
||||
await getAwsAuthToken({
|
||||
region,
|
||||
roleArn
|
||||
});
|
||||
this.logger.debug({roleArn}, `(roleArn) got aws access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials = {...credentials, accessKeyId, secretAccessKey, sessionToken};
|
||||
}
|
||||
else if (vendor === 'verbio' && credentials.client_id && credentials.client_secret) {
|
||||
const {access_token, servedFromCache} = await getVerbioAccessToken(credentials);
|
||||
this.logger.debug({client_id: credentials.client_id},
|
||||
`got verbio access token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials.access_token = access_token;
|
||||
}
|
||||
else if (vendor == 'aws' && !JAMBONES_AWS_TRANSCRIBE_USE_GRPC) {
|
||||
/* get AWS access token */
|
||||
const {accessKeyId, secretAccessKey, securityToken, region } = credentials;
|
||||
if (!securityToken) {
|
||||
const { servedFromCache, ...newCredentials} = await getAwsAuthToken({accessKeyId, secretAccessKey, region});
|
||||
this.logger.debug({newCredentials}, `got aws security token ${servedFromCache ? 'from cache' : ''}`);
|
||||
credentials = {...newCredentials, region};
|
||||
}
|
||||
}
|
||||
|
||||
return credentials;
|
||||
}
|
||||
|
||||
async _fallback() {
|
||||
get canFallback() {
|
||||
return this.fallbackVendor && this.isHandledByPrimaryProvider && !this.cs.hasFallbackAsr;
|
||||
}
|
||||
|
||||
async _initFallback() {
|
||||
assert(this.fallbackVendor, 'fallback failed without fallbackVendor configuration');
|
||||
this.isHandledByPrimaryProvider = false;
|
||||
this.logger.info(`Failed to use primary STT provider, fallback to ${this.fallbackVendor}`);
|
||||
this.vendor = this.fallbackVendor;
|
||||
this.language = this.fallbackLanguage;
|
||||
this.label = this.fallbackLabel;
|
||||
this.isHandledByPrimaryProvider = false;
|
||||
this.cs.hasFallbackAsr = true;
|
||||
this.vendor = this.cs.fallbackSpeechRecognizerVendor = this.fallbackVendor;
|
||||
this.language = this.cs.fallbackSpeechRecognizerLanguage = this.fallbackLanguage;
|
||||
this.label = this.cs.fallbackSpeechRecognizerLabel = this.fallbackLabel;
|
||||
this.data.recognizer.vendor = this.vendor;
|
||||
this.data.recognizer.language = this.language;
|
||||
this.data.recognizer.label = this.label;
|
||||
this.sttCredentials = await this._initSpeechCredentials(this.cs, this.vendor, this.label);
|
||||
// cleanup previous listener from previous vendor
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
async compileHintsForCobalt(ep, hostport, model, token, hints) {
|
||||
@@ -240,6 +301,20 @@ class SttTask extends Task {
|
||||
_doContinuousAsrWithDeepgram(asrTimeout) {
|
||||
/* deepgram has an utterance_end_ms property that simplifies things */
|
||||
assert(this.vendor === 'deepgram');
|
||||
if (asrTimeout < 1000) {
|
||||
this.notifyError({
|
||||
msg: 'ASR error',
|
||||
details:`asrTimeout ${asrTimeout} is too short for deepgram; setting it to 1000ms`
|
||||
});
|
||||
asrTimeout = 1000;
|
||||
}
|
||||
else if (asrTimeout > 5000) {
|
||||
this.notifyError({
|
||||
msg: 'ASR error',
|
||||
details:`asrTimeout ${asrTimeout} is too long for deepgram; setting it to 5000ms`
|
||||
});
|
||||
asrTimeout = 5000;
|
||||
}
|
||||
this.logger.debug(`_doContinuousAsrWithDeepgram - setting utterance_end_ms to ${asrTimeout}`);
|
||||
const dgOptions = this.data.recognizer.deepgramOptions = this.data.recognizer.deepgramOptions || {};
|
||||
dgOptions.utteranceEndMs = dgOptions.utteranceEndMs || asrTimeout;
|
||||
@@ -258,8 +333,8 @@ class SttTask extends Task {
|
||||
message: 'STT failure reported by vendor',
|
||||
detail: evt.error,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor ${this.vendor}: ${evt.error}`});
|
||||
}
|
||||
|
||||
_onVendorConnectFailure(cs, _ep, evt) {
|
||||
@@ -271,8 +346,8 @@ class SttTask extends Task {
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Failed connecting to ${this.vendor} speech recognizer: ${reason}`,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, `Error generating alert for ${this.vendor} connection failure`));
|
||||
this.notifyError({msg: 'ASR error', details:`Failed connecting to speech vendor ${this.vendor}: ${reason}`});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -45,6 +45,10 @@ class Task extends Emitter {
|
||||
return this.name;
|
||||
}
|
||||
|
||||
set disableTracing(val) {
|
||||
this._disableTracing = val;
|
||||
}
|
||||
|
||||
toJSON() {
|
||||
return this.data;
|
||||
}
|
||||
@@ -177,15 +181,16 @@ class Task extends Emitter {
|
||||
// If actionHook delay action is configured, and ws application have not responded yet any verb for actionHook
|
||||
// We have to transfer the task to call-session to await on next ws command verbs, and also run action Hook
|
||||
// delay actions
|
||||
if (this.hookDelayActionOpts) {
|
||||
this.emit('ActionHookDelayActionOptions', this.hookDelayActionOpts);
|
||||
}
|
||||
//if (this.hookDelayActionOpts) {
|
||||
// this.emit('ActionHookDelayActionOptions', this.hookDelayActionOpts);
|
||||
//}
|
||||
}
|
||||
if (expectResponse && json && Array.isArray(json)) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
if (tasks && tasks.length > 0) {
|
||||
this.callSession.replaceApplication(tasks);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -193,6 +198,7 @@ class Task extends Emitter {
|
||||
span.end();
|
||||
throw err;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -272,6 +278,7 @@ class Task extends Emitter {
|
||||
delete obj.requestor;
|
||||
delete obj.notifier;
|
||||
obj.tasks = cs.getRemainingTaskData();
|
||||
obj.callInfo = cs.callInfo.toJSON();
|
||||
if (opts && obj.tasks.length > 0) {
|
||||
const key = Object.keys(obj.tasks[0])[0];
|
||||
Object.assign(obj.tasks[0][key], {_: opts});
|
||||
|
||||
@@ -12,10 +12,13 @@ const {
|
||||
NvidiaTranscriptionEvents,
|
||||
JambonzTranscriptionEvents,
|
||||
TranscribeStatus,
|
||||
AssemblyAiTranscriptionEvents
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
} = require('../utils/constants.json');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const SttTask = require('./stt-task');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
|
||||
const STT_LISTEN_SPAN_NAME = 'stt-listen';
|
||||
|
||||
@@ -73,7 +76,20 @@ class TaskTranscribe extends SttTask {
|
||||
return this.channel === 2 || this.separateRecognitionPerChannel && this.ep2;
|
||||
}
|
||||
|
||||
async exec(cs, {ep, ep2}) {
|
||||
async exec(cs, obj) {
|
||||
try {
|
||||
await this.handling(cs, obj);
|
||||
} catch (error) {
|
||||
if (error instanceof SpeechCredentialError) {
|
||||
this.logger.info('Transcribe failed due to SpeechCredentialError, finished!');
|
||||
this.notifyTaskDone();
|
||||
return;
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async handling(cs, {ep, ep2}) {
|
||||
await super.exec(cs, {ep, ep2});
|
||||
|
||||
if (this.data.recognizer.vendor === 'nuance') {
|
||||
@@ -104,12 +120,15 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid)
|
||||
.catch(() => {/*already logged error */});
|
||||
|
||||
await this.awaitTaskDone();
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
this.parentTask && this.parentTask.emit('error', err);
|
||||
if (!(await this._startFallback(cs, ep, {error: err}))) {
|
||||
this.logger.info(err, 'TaskTranscribe:exec - error');
|
||||
this.parentTask && this.parentTask.emit('error', err);
|
||||
this.removeCustomEventListeners();
|
||||
return;
|
||||
}
|
||||
}
|
||||
await this.awaitTaskDone();
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
@@ -123,7 +142,7 @@ class TaskTranscribe extends SttTask {
|
||||
})
|
||||
.catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
|
||||
}
|
||||
if (this.transcribing2 && 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'));
|
||||
@@ -194,8 +213,8 @@ class TaskTranscribe extends SttTask {
|
||||
this.bugname = `${this.bugname_prefix}azure_transcribe`;
|
||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
||||
this._onNoAudio.bind(this, cs, ep, channel));
|
||||
//this.addCustomEventListener(ep, AzureTranscriptionEvents.NoSpeechDetected,
|
||||
// this._onNoAudio.bind(this, cs, ep, channel));
|
||||
break;
|
||||
case 'nuance':
|
||||
this.bugname = `${this.bugname_prefix}nuance_transcribe`;
|
||||
@@ -212,7 +231,7 @@ class TaskTranscribe extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
|
||||
/* if app sets deepgramOptions.utteranceEndMs they essentially want continuous asr */
|
||||
if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
||||
//if (opts.DEEPGRAM_SPEECH_UTTERANCE_END_MS) this.isContinuousAsr = true;
|
||||
|
||||
break;
|
||||
case 'soniox':
|
||||
@@ -220,6 +239,13 @@ class TaskTranscribe extends SttTask {
|
||||
this.addCustomEventListener(ep, SonioxTranscriptionEvents.Transcription,
|
||||
this._onTranscription.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'verbio':
|
||||
this.bugname = `${this.bugname_prefix}verbio_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, VerbioTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'cobalt':
|
||||
this.bugname = `${this.bugname_prefix}cobalt_transcribe`;
|
||||
this.addCustomEventListener(ep, CobaltTranscriptionEvents.Transcription,
|
||||
@@ -277,6 +303,22 @@ class TaskTranscribe extends SttTask {
|
||||
this._onVendorConnectFailure.bind(this, cs, ep, channel));
|
||||
break;
|
||||
|
||||
case 'speechmatics':
|
||||
this.bugname = `${this.bugname_prefix}speechmatics_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, SpeechmaticsTranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Info,
|
||||
this._onSpeechmaticsInfo.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.RecognitionStarted,
|
||||
this._onSpeechmaticsRecognitionStarted.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, SpeechmaticsTranscriptionEvents.Error,
|
||||
this._onSpeechmaticsError.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
||||
@@ -336,6 +378,12 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (this.vendor === 'deepgram' && evt.type === 'UtteranceEnd') {
|
||||
/* we will only get this when we have set utterance_end_ms */
|
||||
|
||||
/* DH: send a speech event when we get UtteranceEnd if they want interim events */
|
||||
if (this.interim) {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram, sending speech event');
|
||||
this._resolve(channel, evt);
|
||||
}
|
||||
if (bufferedTranscripts.length === 0) {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd event from deepgram but no buffered transcripts');
|
||||
}
|
||||
@@ -420,9 +468,13 @@ class TaskTranscribe extends SttTask {
|
||||
/* here is where we return a final transcript */
|
||||
this.logger.debug({evt}, 'TaskTranscribe:_onTranscription - sending final transcript');
|
||||
this._resolve(channel, evt);
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google']
|
||||
.includes(this.vendor)) this._startTranscribing(cs, ep, channel);
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google'].includes(this.vendor) &&
|
||||
!this.vendor.startsWith('custom:')) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription - restarting transcribe');
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
}
|
||||
}
|
||||
else {
|
||||
@@ -445,26 +497,32 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
async _resolve(channel, evt) {
|
||||
/* we've got a transcript, so end the otel child span for this channel */
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': 'transcript',
|
||||
'stt.result': JSON.stringify(evt)
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
if (evt.is_final) {
|
||||
/* we've got a final transcript, so end the otel child span for this channel */
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.label': this.label || 'None',
|
||||
'stt.resolve': 'transcript',
|
||||
'stt.result': JSON.stringify(evt)
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
}
|
||||
|
||||
if (this.transcriptionHook) {
|
||||
const b3 = this.getTracingPropagation();
|
||||
const httpHeaders = b3 && {b3};
|
||||
const payload = {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
...(evt.alternatives && {speech: evt}),
|
||||
...(evt.type && {speechEvent: evt})
|
||||
};
|
||||
try {
|
||||
const json = await this.cs.requestor.request('verb:hook', this.transcriptionHook, {
|
||||
...this.cs.callInfo,
|
||||
...httpHeaders,
|
||||
speech: evt
|
||||
});
|
||||
this.logger.info({json}, 'sent transcriptionHook');
|
||||
this.logger.debug({payload}, 'sending transcriptionHook');
|
||||
const json = await this.cs.requestor.request('verb:hook', this.transcriptionHook, payload);
|
||||
this.logger.info({json}, 'completed transcriptionHook');
|
||||
if (json && Array.isArray(json) && !this.parentTask) {
|
||||
const makeTask = require('./make_task');
|
||||
const tasks = normalizeJambones(this.logger, json).map((tdata) => makeTask(this.logger, tdata));
|
||||
@@ -485,7 +543,7 @@ class TaskTranscribe extends SttTask {
|
||||
this._clearTimer();
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
else {
|
||||
else if (evt.is_final) {
|
||||
/* start another child span for this channel */
|
||||
const {span, ctx} = this.startChildSpan(`${STT_LISTEN_SPAN_NAME}:${channel}`);
|
||||
this.childSpan[channel - 1] = {span, ctx};
|
||||
@@ -498,7 +556,8 @@ class TaskTranscribe extends SttTask {
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': 'timeout'
|
||||
'stt.resolve': 'timeout',
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
@@ -515,7 +574,8 @@ class TaskTranscribe extends SttTask {
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': 'max duration exceeded'
|
||||
'stt.resolve': 'max duration exceeded',
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
@@ -534,10 +594,8 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
}
|
||||
|
||||
async _onJambonzError(cs, _ep, evt) {
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
if (this.paused) return;
|
||||
if (this.isHandledByPrimaryProvider && this.fallbackVendor) {
|
||||
async _startFallback(cs, _ep, evt) {
|
||||
if (this.canFallback) {
|
||||
_ep.stopTranscription({
|
||||
vendor: this.vendor,
|
||||
bugname: this.bugname
|
||||
@@ -545,47 +603,84 @@ class TaskTranscribe extends SttTask {
|
||||
.catch((err) => this.logger.error({err}, `Error stopping transcription for primary vendor ${this.vendor}`));
|
||||
const {updateSpeechCredentialLastUsed} = require('../utils/db-utils')(this.logger, cs.srf);
|
||||
try {
|
||||
await this._fallback();
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'in progress'});
|
||||
await this._initFallback();
|
||||
let channel = 1;
|
||||
if (this.ep !== _ep) {
|
||||
channel = 2;
|
||||
}
|
||||
this[`_speechHandlersSet_${channel}`] = false;
|
||||
this._startTranscribing(cs, _ep, channel);
|
||||
updateSpeechCredentialLastUsed(this.sttCredentials.speech_credential_sid);
|
||||
return;
|
||||
return true;
|
||||
} catch (error) {
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'not available'});
|
||||
this.logger.info({error}, `There is error while falling back to ${this.fallbackVendor}`);
|
||||
}
|
||||
} else {
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
this.logger.debug('transcribe:_startFallback no condition for falling back');
|
||||
this.notifyError({ msg: 'ASR error',
|
||||
details:`STT Vendor ${this.vendor} error: ${evt.error || evt.reason}`, failover: 'not available'});
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
|
||||
vendor: this.vendor,
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
this.notifyError({msg: 'ASR error', details:`Custom speech vendor ${this.vendor} error: ${evt.error}`});
|
||||
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;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
|
||||
if (this.vendor === 'nuance') {
|
||||
const {code, error} = evt;
|
||||
if (code === 404 && error === 'No speech') return this._resolve('timeout');
|
||||
if (code === 413 && error === 'Too much speech') return this._resolve('timeout');
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
writeAlerts({
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
message: `Custom speech vendor ${this.vendor} error: ${evt.error}`,
|
||||
vendor: this.vendor,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for jambonz custom connection failure'));
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_onVendorConnectFailure(cs, _ep, channel, evt) {
|
||||
async _onVendorConnectFailure(cs, _ep, channel, evt) {
|
||||
super._onVendorConnectFailure(cs, _ep, evt);
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': 'connection failure'
|
||||
'stt.resolve': 'connection failure',
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
}
|
||||
this.notifyTaskDone();
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _onSpeechmaticsRecognitionStarted(_cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onSpeechmaticsRecognitionStarted');
|
||||
}
|
||||
|
||||
async _onSpeechmaticsInfo(_cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onSpeechmaticsInfo');
|
||||
}
|
||||
|
||||
async _onSpeechmaticsErrror(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
_startAsrTimer(channel) {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const Task = require('./task');
|
||||
const { TaskPreconditions } = require('../utils/constants');
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
|
||||
class TtsTask extends Task {
|
||||
|
||||
@@ -10,6 +11,12 @@ class TtsTask extends Task {
|
||||
this.preconditions = TaskPreconditions.Endpoint;
|
||||
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
/**
|
||||
* Task use taskInlcudeSynthesizer to identify
|
||||
* if taskInlcudeSynthesizer === true, use label from verb.synthesizer, even it's empty
|
||||
* if taskInlcudeSynthesizer === false, use label from application.synthesizer
|
||||
*/
|
||||
this.taskInlcudeSynthesizer = !!this.data.synthesizer;
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
@@ -17,24 +24,38 @@ class TtsTask extends Task {
|
||||
|
||||
async exec(cs) {
|
||||
super.exec(cs);
|
||||
if (cs.synthesizer) {
|
||||
this.options = {...cs.synthesizer.options, ...this.options};
|
||||
this.data.synthesizer = this.data.synthesizer || {};
|
||||
for (const k in cs.synthesizer) {
|
||||
const newValue = this.data.synthesizer && this.data.synthesizer[k] !== undefined ?
|
||||
this.data.synthesizer[k] :
|
||||
cs.synthesizer[k];
|
||||
|
||||
if (Array.isArray(newValue)) {
|
||||
this.data.synthesizer[k] = [...(this.data.synthesizer[k] || []), ...cs.synthesizer[k]];
|
||||
} else if (typeof newValue === 'object' && newValue !== null) {
|
||||
this.data.synthesizer[k] = { ...(this.data.synthesizer[k] || {}), ...cs.synthesizer[k] };
|
||||
} else {
|
||||
this.data.synthesizer[k] = newValue;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _synthesizeWithSpecificVendor(cs, ep, {
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
label,
|
||||
disableTtsStreaming,
|
||||
preCache
|
||||
}) {
|
||||
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 engine = this.synthesizer.engine || cs.synthesizer?.engine || 'neural';
|
||||
const salt = cs.callSid;
|
||||
|
||||
let credentials = cs.getSpeechCredentials(vendor, 'tts', label);
|
||||
if (!credentials) {
|
||||
throw new SpeechCredentialError(
|
||||
`No text-to-speech service credentials for ${vendor} with labels: ${label} have been configured`);
|
||||
}
|
||||
/* parse Nuance voices into name and model */
|
||||
let model;
|
||||
if (vendor === 'nuance' && voice) {
|
||||
@@ -62,27 +83,50 @@ class TtsTask extends Task {
|
||||
credentials.optimize_streaming_latency = this.options.optimize_streaming_latency
|
||||
|| credentials.optimize_streaming_latency;
|
||||
voice = this.options.voice_id || voice;
|
||||
} else if (vendor === 'rimelabs') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
} else if (vendor === 'whisper') {
|
||||
credentials = credentials || {};
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
} else if (vendor === 'verbio') {
|
||||
credentials = credentials || {};
|
||||
credentials.engine_version = this.options.engine_version || credentials.engine_version;
|
||||
} else if (vendor === 'playht') {
|
||||
credentials = credentials || {};
|
||||
credentials.voice_engine = this.options.voice_engine || credentials.voice_engine;
|
||||
}
|
||||
|
||||
/**
|
||||
* note on cache_speech_handles. This was found to be risky.
|
||||
* It can cause a crash in the following sequence on a single call:
|
||||
* 1. Stream tts on vendor A with cache_speech_handles=1, then
|
||||
* 2. Stream tts on vendor B with cache_speech_handles=1
|
||||
*
|
||||
* we previously tried to track when vendors were switched and manage the flag accordingly,
|
||||
* but it difficult to track all the scenarios and the benefit (slightly faster start to tts playout)
|
||||
* is probably minimal. DH.
|
||||
*/
|
||||
ep.set({
|
||||
tts_engine: vendor,
|
||||
tts_engine: vendor.startsWith('custom:') ? 'custom' : vendor,
|
||||
tts_voice: voice,
|
||||
cache_speech_handles: 1,
|
||||
}).catch((err) => this.logger.info({err}, `${this.name}: Error setting tts_engine on endpoint`));
|
||||
//cache_speech_handles: !cs.currentTtsVendor || cs.currentTtsVendor === vendor ? 1 : 0,
|
||||
cache_speech_handles: 0,
|
||||
}).catch((err) => this.logger.info({err}, 'Error setting tts_engine on endpoint'));
|
||||
// set the current vendor on the call session
|
||||
// If vendor is changed from the previous one, then reset the cache_speech_handles flag
|
||||
//cs.currentTtsVendor = vendor;
|
||||
|
||||
if (!preCache) this.logger.info({vendor, language, voice, model}, `${this.name}:exec`);
|
||||
if (!preCache && !this._disableTracing) this.logger.info({vendor, language, voice, model}, 'TaskSay:exec');
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_NOT_PROVISIONED,
|
||||
vendor
|
||||
vendor,
|
||||
target_sid: cs.callSid
|
||||
}).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');
|
||||
throw new SpeechCredentialError('no provisioned speech credentials for TTS');
|
||||
}
|
||||
// synthesize all of the text elements
|
||||
let lastUpdated = false;
|
||||
@@ -93,11 +137,12 @@ class TtsTask extends Task {
|
||||
if (text.startsWith('silence_stream://')) return text;
|
||||
|
||||
/* otel: trace time for tts */
|
||||
if (!preCache && !this.parentTask) {
|
||||
if (!preCache && !this._disableTracing) {
|
||||
const {span} = this.startChildSpan('tts-generation', {
|
||||
'tts.vendor': vendor,
|
||||
'tts.language': language,
|
||||
'tts.voice': voice
|
||||
'tts.voice': voice,
|
||||
'tts.label': label || 'None',
|
||||
});
|
||||
this.otelSpan = span;
|
||||
}
|
||||
@@ -114,11 +159,10 @@ class TtsTask extends Task {
|
||||
credentials,
|
||||
options: this.options,
|
||||
disableTtsCache : this.disableTtsCache,
|
||||
disableTtsStreaming,
|
||||
preCache
|
||||
renderForCaching: preCache
|
||||
});
|
||||
if (!filePath.startsWith('say:')) {
|
||||
this.logger.debug(`file ${filePath}, served from cache ${servedFromCache}`);
|
||||
this.logger.debug(`Say: file ${filePath}, served from cache ${servedFromCache}`);
|
||||
if (filePath) cs.trackTmpFile(filePath);
|
||||
if (this.otelSpan) {
|
||||
this.otelSpan.setAttributes({'tts.cached': servedFromCache});
|
||||
@@ -129,7 +173,7 @@ class TtsTask extends Task {
|
||||
lastUpdated = true;
|
||||
updateSpeechCredentialLastUsed(credentials.speech_credential_sid).catch(() => {/* logged error */});
|
||||
}
|
||||
if (!servedFromCache && rtt && !preCache) {
|
||||
if (!servedFromCache && rtt && !preCache && !this._disableTracing) {
|
||||
this.notifyStatus({
|
||||
event: 'synthesized-audio',
|
||||
vendor,
|
||||
@@ -140,7 +184,7 @@ class TtsTask extends Task {
|
||||
}
|
||||
}
|
||||
else {
|
||||
this.logger.debug('a streaming tts api will be used');
|
||||
this.logger.debug('Say: a streaming tts api will be used');
|
||||
const modifiedPath = filePath.replace('say:{', `say:{session-uuid=${ep.uuid},`);
|
||||
return modifiedPath;
|
||||
}
|
||||
@@ -152,9 +196,9 @@ class TtsTask extends Task {
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
vendor,
|
||||
detail: err.message
|
||||
detail: err.message,
|
||||
target_sid: cs.callSid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for tts failure'));
|
||||
this.notifyError({msg: 'TTS error', details: err.message || err});
|
||||
throw err;
|
||||
}
|
||||
};
|
||||
@@ -165,6 +209,7 @@ class TtsTask extends Task {
|
||||
this.logger.info(err, 'TaskSay:exec error');
|
||||
throw err;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
_validateURL(urlString) {
|
||||
|
||||
182
lib/utils/action-hook-delay.js
Normal file
182
lib/utils/action-hook-delay.js
Normal file
@@ -0,0 +1,182 @@
|
||||
const makeTask = require('../tasks/make_task');
|
||||
const Emitter = require('events');
|
||||
const { normalizeJambones } = require('@jambonz/verb-specifications');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
|
||||
/**
|
||||
* ActionHookDelayProcessor
|
||||
* @extends Emitter
|
||||
*
|
||||
* @param {Object} logger - logger instance
|
||||
* @param {Object} opts - options
|
||||
* @param {Object} cs - call session
|
||||
* @param {Object} ep - endpoint
|
||||
*
|
||||
* @emits {Event} 'giveup' - when associated giveup timer expires
|
||||
*
|
||||
* Ref:https://www.jambonz.org/docs/supporting-articles/handling-action-hook-delays/
|
||||
*/
|
||||
class ActionHookDelayProcessor extends Emitter {
|
||||
constructor(logger, opts, cs) {
|
||||
super();
|
||||
this.logger = logger;
|
||||
this.cs = cs;
|
||||
this._active = false;
|
||||
|
||||
const enabled = this.init(opts);
|
||||
if (enabled && this.noResponseTimeout &&
|
||||
(!this.actions || !Array.isArray(this.actions) || this.actions.length === 0)) {
|
||||
throw new Error('ActionHookDelayProcessor: no actions specified');
|
||||
}
|
||||
else if (enabled && this.actions &&
|
||||
this.actions.some((a) => !a.verb || ![TaskName.Say, TaskName.Play].includes(a.verb))) {
|
||||
throw new Error(`ActionHookDelayProcessor: invalid actions specified: ${JSON.stringify(this.actions)}`);
|
||||
}
|
||||
}
|
||||
|
||||
get properties() {
|
||||
return {
|
||||
actions: this.actions,
|
||||
retries: this.retries,
|
||||
noResponseTimeout: this.noResponseTimeout,
|
||||
noResponseGiveUpTimeout: this.noResponseGiveUpTimeout
|
||||
};
|
||||
}
|
||||
|
||||
get ep() {
|
||||
return this.cs.ep;
|
||||
}
|
||||
|
||||
init(opts) {
|
||||
this.logger.debug({opts}, 'ActionHookDelayProcessor#init');
|
||||
|
||||
this.actions = opts.actions;
|
||||
this.retries = opts.retries || 0;
|
||||
this.noResponseTimeout = opts.noResponseTimeout;
|
||||
this.noResponseGiveUpTimeout = opts.noResponseGiveUpTimeout;
|
||||
this.giveUpActions = opts.giveUpActions;
|
||||
|
||||
// return false if these options actually disable the ahdp
|
||||
return ('enable' in opts && opts.enable === true) ||
|
||||
('enabled' in opts && opts.enabled === true) ||
|
||||
(!('enable' in opts) && !('enabled' in opts));
|
||||
}
|
||||
|
||||
start() {
|
||||
this.logger.debug('ActionHookDelayProcessor#start');
|
||||
if (this._active) {
|
||||
this.logger.debug('ActionHookDelayProcessor#start: already started due to prior gather which is continuing');
|
||||
return;
|
||||
}
|
||||
this._active = true;
|
||||
this._retryCount = 0;
|
||||
if (this.noResponseTimeout > 0) {
|
||||
const timeoutMs = this.noResponseTimeout * 1000;
|
||||
this._noResponseTimer = setTimeout(this._onNoResponseTimer.bind(this), timeoutMs);
|
||||
} else {
|
||||
this.logger.debug(
|
||||
'ActionHookDelayProcessor#start: noResponseTimeout is 0 or undefined hence not calling _onNoResponseTimer'
|
||||
);
|
||||
}
|
||||
|
||||
if (this.noResponseGiveUpTimeout > 0) {
|
||||
const timeoutMs = this.noResponseGiveUpTimeout * 1000;
|
||||
this._noResponseGiveUpTimer = setTimeout(this._onNoResponseGiveUpTimer.bind(this), timeoutMs);
|
||||
}
|
||||
}
|
||||
|
||||
async stop() {
|
||||
this._active = false;
|
||||
|
||||
if (this._noResponseTimer) {
|
||||
clearTimeout(this._noResponseTimer);
|
||||
this._noResponseTimer = null;
|
||||
}
|
||||
if (this._noResponseGiveUpTimer) {
|
||||
clearTimeout(this._noResponseGiveUpTimer);
|
||||
this._noResponseGiveUpTimer = null;
|
||||
}
|
||||
if (this._taskInProgress) {
|
||||
this.logger.debug(`ActionHookDelayProcessor#stop: stopping ${this._taskInProgress.name}`);
|
||||
|
||||
this._sayResolver = () => {
|
||||
this.logger.debug('ActionHookDelayProcessor#stop: play/say is done, continue on..');
|
||||
//this._taskInProgress.kill(this.cs);
|
||||
this._taskInProgress = null;
|
||||
};
|
||||
|
||||
/* we let Say finish, but interrupt Play */
|
||||
if (TaskName.Play === this._taskInProgress.name) {
|
||||
await this._taskInProgress.kill(this.cs);
|
||||
}
|
||||
return new Promise((resolve) => this._sayResolver = resolve);
|
||||
}
|
||||
this.logger.debug('ActionHookDelayProcessor#stop returning');
|
||||
}
|
||||
|
||||
_onNoResponseTimer() {
|
||||
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer');
|
||||
this._noResponseTimer = null;
|
||||
|
||||
/* get the next play or say action */
|
||||
const verb = this.actions[this._retryCount % this.actions.length];
|
||||
|
||||
const t = normalizeJambones(this.logger, [verb]);
|
||||
this.logger.debug({verb}, 'ActionHookDelayProcessor#_onNoResponseTimer: starting action');
|
||||
try {
|
||||
this._taskInProgress = makeTask(this.logger, t[0]);
|
||||
this._taskInProgress.disableTracing = true;
|
||||
this._taskInProgress.exec(this.cs, {ep: this.ep});
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'ActionHookDelayProcessor#_onNoResponseTimer: error starting action');
|
||||
this._taskInProgress = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.ep.once('playback-start', (evt) => {
|
||||
this.logger.debug({evt}, 'got playback-start');
|
||||
if (!this._active) {
|
||||
this.logger.info({evt}, 'ActionHookDelayProcessor#_onNoResponseTimer: killing audio immediately');
|
||||
|
||||
/* note: in race condition we may have just hung up and cs.ep cleared */
|
||||
this.ep?.api('uuid_break', this.ep?.uuid)
|
||||
.catch((err) => this.logger.info(err,
|
||||
'ActionHookDelayProcessor#_onNoResponseTimer Error killing audio'));
|
||||
}
|
||||
});
|
||||
|
||||
this.ep.once('playback-stop', (evt) => {
|
||||
this._taskInProgress = null;
|
||||
if (this._sayResolver) {
|
||||
/* we were waiting for the play to finish before continuing to next task */
|
||||
this.logger.debug({evt}, 'ActionHookDelayProcessor#_onNoResponseTimer got playback-stop');
|
||||
this._sayResolver();
|
||||
this._sayResolver = null;
|
||||
}
|
||||
else {
|
||||
/* possibly start the no response timer again */
|
||||
if (this._active && this.retries > 0 && this._retryCount < this.retries && this.noResponseTimeout > 0) {
|
||||
this.logger.debug({evt}, 'ActionHookDelayProcessor#_onNoResponseTimer: playback-stop on play/say action');
|
||||
const timeoutMs = this.noResponseTimeout * 1000;
|
||||
this._noResponseTimer = setTimeout(this._onNoResponseTimer.bind(this), timeoutMs);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
this._retryCount++;
|
||||
}
|
||||
|
||||
_onNoResponseGiveUpTimer() {
|
||||
this._active = false;
|
||||
if (!this.giveUpActions) {
|
||||
this.logger.info('ActionHookDelayProcessor#_onNoResponseGiveUpTimer');
|
||||
this.stop().catch((err) => {});
|
||||
this.emit('giveup');
|
||||
} else {
|
||||
this.logger.info('ActionHookDelayProcessor#_onNoResponseGiveUpTimer - giveUpActions');
|
||||
this.emit('giveupWithTasks', this.giveUpActions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = ActionHookDelayProcessor;
|
||||
@@ -210,7 +210,8 @@ module.exports = (logger) => {
|
||||
account_sid: cs.accountSid,
|
||||
alert_type: AlertType.STT_FAILURE,
|
||||
vendor: vendor,
|
||||
detail: err.message
|
||||
detail: err.message,
|
||||
target_sid: cs.callSid
|
||||
});
|
||||
}).catch((err) => logger.info({err}, 'Error generating alert for tts failure'));
|
||||
|
||||
@@ -245,7 +246,10 @@ module.exports = (logger) => {
|
||||
const amd = ep.amd = new Amd(logger, cs, opts);
|
||||
const {vendor, language} = amd;
|
||||
let sttCredentials = amd.sttCredentials;
|
||||
const hints = voicemailHints[language] || [];
|
||||
// hints from configuration might be too long for specific language and vendor that make transcribe freeswitch
|
||||
// modules cannot connect to the vendor. hints is used in next step to validate if the transcription
|
||||
// matchs voice mail hints.
|
||||
const hints = [];
|
||||
|
||||
if (vendor === 'nuance' && sttCredentials.client_id) {
|
||||
/* get nuance access token */
|
||||
|
||||
@@ -26,7 +26,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
return this.tasks.size;
|
||||
}
|
||||
|
||||
async newTask(type, opts) {
|
||||
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, skipped`);
|
||||
@@ -52,6 +52,7 @@ class BackgroundTaskManager extends Emitter {
|
||||
if (task) {
|
||||
this.tasks.set(type, task);
|
||||
}
|
||||
if (task && sticky) task.sticky = true;
|
||||
return task;
|
||||
}
|
||||
|
||||
@@ -116,7 +117,8 @@ 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._bargeInHandled = false;
|
||||
this.newTask('bargeIn', opts, true);
|
||||
}
|
||||
return;
|
||||
})
|
||||
@@ -185,6 +187,8 @@ class BackgroundTaskManager extends Emitter {
|
||||
}
|
||||
|
||||
_bargeInTaskCompleted(evt) {
|
||||
if (this._bargeInHandled) return;
|
||||
this._bargeInHandled = true;
|
||||
this.logger.debug({evt},
|
||||
'BackgroundTaskManager:_bargeInTaskCompleted on event from background bargeIn, emitting bargein-done event');
|
||||
this.emit('bargeIn-done', evt);
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
"Leave": "leave",
|
||||
"Lex": "lex",
|
||||
"Listen": "listen",
|
||||
"Llm": "llm",
|
||||
"Message": "message",
|
||||
"Pause": "pause",
|
||||
"Play": "play",
|
||||
@@ -97,6 +98,10 @@
|
||||
"Transcription": "soniox_transcribe::transcription",
|
||||
"Error": "soniox_transcribe::error"
|
||||
},
|
||||
"VerbioTranscriptionEvents": {
|
||||
"Transcription": "verbio_transcribe::transcription",
|
||||
"Error": "verbio_transcribe::error"
|
||||
},
|
||||
"CobaltTranscriptionEvents": {
|
||||
"Transcription": "cobalt_speech::transcription",
|
||||
"CompileContext": "cobalt_speech::compile_context_response",
|
||||
@@ -122,6 +127,14 @@
|
||||
"NoSpeechDetected": "azure_transcribe::no_speech_detected",
|
||||
"VadDetected": "azure_transcribe::vad_detected"
|
||||
},
|
||||
"SpeechmaticsTranscriptionEvents": {
|
||||
"Transcription": "speechmatics_transcribe::transcription",
|
||||
"Info": "speechmatics_transcribe::info",
|
||||
"RecognitionStarted": "speechmatics_transcribe::recognition_started",
|
||||
"ConnectFailure": "speechmatics_transcribe::connect_failed",
|
||||
"Connect": "speechmatics_transcribe::connect",
|
||||
"Error": "speechmatics_transcribe::error"
|
||||
},
|
||||
"JambonzTranscriptionEvents": {
|
||||
"Transcription": "jambonz_transcribe::transcription",
|
||||
"ConnectFailure": "jambonz_transcribe::connect_failed",
|
||||
@@ -134,6 +147,9 @@
|
||||
"ConnectFailure": "assemblyai_transcribe::connect_failed",
|
||||
"Connect": "assemblyai_transcribe::connect"
|
||||
},
|
||||
"VadDetection": {
|
||||
"Detection": "vad_detect:detection"
|
||||
},
|
||||
"ListenEvents": {
|
||||
"Connect": "mod_audio_fork::connect",
|
||||
"ConnectFailure": "mod_audio_fork::connect_failed",
|
||||
@@ -151,6 +167,13 @@
|
||||
"StandbyEnter": "standby-enter",
|
||||
"StandbyExit": "standby-exit"
|
||||
},
|
||||
"LlmEvents_OpenAI": {
|
||||
"Error": "error",
|
||||
"Connect": "openai_s2s::connect",
|
||||
"ConnectFailure": "openai_s2s::connect_failed",
|
||||
"Disconnect": "openai_s2s::disconnect",
|
||||
"ServerEvent": "openai_s2s::server_event"
|
||||
},
|
||||
"QueueResults": {
|
||||
"Bridged": "bridged",
|
||||
"Error": "error",
|
||||
@@ -177,6 +200,8 @@
|
||||
"dial:confirm",
|
||||
"verb:hook",
|
||||
"verb:status",
|
||||
"llm:event",
|
||||
"llm:tool-call",
|
||||
"jambonz:error"
|
||||
],
|
||||
"RecordState": {
|
||||
|
||||
@@ -41,6 +41,7 @@ const speechMapper = (cred) => {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.access_key_id = o.access_key_id;
|
||||
obj.secret_access_key = o.secret_access_key;
|
||||
obj.role_arn = o.role_arn;
|
||||
obj.aws_region = o.aws_region;
|
||||
}
|
||||
else if ('microsoft' === obj.vendor) {
|
||||
@@ -76,6 +77,7 @@ const speechMapper = (cred) => {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.deepgram_stt_uri = o.deepgram_stt_uri;
|
||||
obj.deepgram_tts_uri = o.deepgram_tts_uri;
|
||||
obj.deepgram_stt_use_tls = o.deepgram_stt_use_tls;
|
||||
}
|
||||
else if ('soniox' === obj.vendor) {
|
||||
@@ -89,25 +91,47 @@ const speechMapper = (cred) => {
|
||||
else if ('cobalt' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.cobalt_server_uri = o.cobalt_server_uri;
|
||||
} else if ('elevenlabs' === obj.vendor) {
|
||||
}
|
||||
else if ('elevenlabs' === 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 ('playht' === obj.vendor) {
|
||||
}
|
||||
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 ('assemblyai' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
} else if ('whisper' === obj.vendor) {
|
||||
}
|
||||
else if ('rimelabs' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
} else if (obj.vendor.startsWith('custom:')) {
|
||||
obj.options = o.options;
|
||||
}
|
||||
else if ('assemblyai' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
}
|
||||
else if ('whisper' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.model_id = o.model_id;
|
||||
}
|
||||
else if ('verbio' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.client_id = o.client_id;
|
||||
obj.client_secret = o.client_secret;
|
||||
obj.engine_version = o.engine_version;
|
||||
}
|
||||
else if ('speechmatics' === obj.vendor) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.api_key = o.api_key;
|
||||
obj.speechmatics_stt_uri = o.speechmatics_stt_uri;
|
||||
}
|
||||
else if (obj.vendor.startsWith('custom:')) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.auth_token = o.auth_token;
|
||||
obj.custom_stt_url = o.custom_stt_url;
|
||||
|
||||
9
lib/utils/error.js
Normal file
9
lib/utils/error.js
Normal file
@@ -0,0 +1,9 @@
|
||||
class SpeechCredentialError extends Error {
|
||||
constructor(msg) {
|
||||
super(msg);
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
SpeechCredentialError
|
||||
};
|
||||
@@ -1,5 +1,5 @@
|
||||
const Mrf = require('drachtio-fsmrf');
|
||||
const ip = require('ip');
|
||||
const os = require('os');
|
||||
const {
|
||||
JAMBONES_MYSQL_HOST,
|
||||
JAMBONES_MYSQL_USER,
|
||||
@@ -12,11 +12,25 @@ const {
|
||||
JAMBONES_TIME_SERIES_HOST,
|
||||
JAMBONES_ESL_LISTEN_ADDRESS,
|
||||
PORT,
|
||||
HTTP_IP,
|
||||
NODE_ENV,
|
||||
} = require('../config');
|
||||
const Registrar = require('@jambonz/mw-registrar');
|
||||
const assert = require('assert');
|
||||
|
||||
function getLocalIp() {
|
||||
const interfaces = os.networkInterfaces();
|
||||
for (const interfaceName in interfaces) {
|
||||
const interface = interfaces[interfaceName];
|
||||
for (const iface of interface) {
|
||||
if (iface.family === 'IPv4' && !iface.internal) {
|
||||
return iface.address;
|
||||
}
|
||||
}
|
||||
}
|
||||
return '127.0.0.1'; // Fallback to localhost if no suitable interface found
|
||||
}
|
||||
|
||||
function initMS(logger, wrapper, ms) {
|
||||
Object.assign(wrapper, {ms, active: true, connects: 1});
|
||||
logger.info(`connected to freeswitch at ${ms.address}`);
|
||||
@@ -138,7 +152,8 @@ function installSrfLocals(srf, logger) {
|
||||
lookupAccountBySid,
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways,
|
||||
lookupClientByAccountAndUsername
|
||||
lookupClientByAccountAndUsername,
|
||||
lookupSystemInformation
|
||||
} = require('@jambonz/db-helpers')({
|
||||
host: JAMBONES_MYSQL_HOST,
|
||||
user: JAMBONES_MYSQL_USER,
|
||||
@@ -171,7 +186,7 @@ function installSrfLocals(srf, logger) {
|
||||
retrieveFromSortedSet,
|
||||
retrieveByPatternSortedSet,
|
||||
sortedSetLength,
|
||||
sortedSetPositionByPattern
|
||||
sortedSetPositionByPattern,
|
||||
} = require('@jambonz/realtimedb-helpers')({}, logger, tracer);
|
||||
const registrar = new Registrar(logger, client);
|
||||
const {
|
||||
@@ -179,6 +194,8 @@ function installSrfLocals(srf, logger) {
|
||||
addFileToCache,
|
||||
getNuanceAccessToken,
|
||||
getIbmAccessToken,
|
||||
getAwsAuthToken,
|
||||
getVerbioAccessToken
|
||||
} = require('@jambonz/speech-utils')({}, logger);
|
||||
const {
|
||||
writeAlerts,
|
||||
@@ -191,7 +208,8 @@ function installSrfLocals(srf, logger) {
|
||||
|
||||
let localIp;
|
||||
try {
|
||||
localIp = ip.address();
|
||||
// Either use the configured IP address or discover it
|
||||
localIp = HTTP_IP || getLocalIp();
|
||||
} catch (err) {
|
||||
logger.error({err}, 'installSrfLocals - error detecting local ipv4 address');
|
||||
}
|
||||
@@ -211,11 +229,13 @@ function installSrfLocals(srf, logger) {
|
||||
lookupAccountCapacitiesBySid,
|
||||
lookupSmppGateways,
|
||||
lookupClientByAccountAndUsername,
|
||||
lookupSystemInformation,
|
||||
updateCallStatus,
|
||||
retrieveCall,
|
||||
listCalls,
|
||||
deleteCall,
|
||||
synthAudio,
|
||||
getAwsAuthToken,
|
||||
addFileToCache,
|
||||
createHash,
|
||||
retrieveHash,
|
||||
@@ -237,7 +257,8 @@ function installSrfLocals(srf, logger) {
|
||||
retrieveFromSortedSet,
|
||||
retrieveByPatternSortedSet,
|
||||
sortedSetLength,
|
||||
sortedSetPositionByPattern
|
||||
sortedSetPositionByPattern,
|
||||
getVerbioAccessToken
|
||||
},
|
||||
parentLogger: logger,
|
||||
getSBC,
|
||||
|
||||
@@ -213,6 +213,8 @@ class SingleDialer extends Emitter {
|
||||
},
|
||||
cbProvisional: (prov) => {
|
||||
const status = {sipStatus: prov.status, sipReason: prov.reason};
|
||||
// Update call-id for sbc outbound INVITE
|
||||
this.callInfo.sbcCallid = prov.get('X-CID');
|
||||
if ([180, 183].includes(prov.status) && prov.body) {
|
||||
if (status.callStatus !== CallStatus.EarlyMedia) {
|
||||
status.callStatus = CallStatus.EarlyMedia;
|
||||
@@ -441,12 +443,14 @@ class SingleDialer extends Emitter {
|
||||
});
|
||||
app.requestor.request('session:adulting', '/adulting', {
|
||||
...cs.callInfo.toJSON(),
|
||||
parentCallInfo: this.parentCallInfo
|
||||
parentCallInfo: this.parentCallInfo.toJSON()
|
||||
}).catch((err) => {
|
||||
newLogger.error({err}, 'doAdulting: error sending adulting request');
|
||||
});
|
||||
|
||||
cs.req = this.req;
|
||||
// fixed hangup an adulting session does not send status callback Completed
|
||||
cs.wrapDialog(this.dlg);
|
||||
cs.exec().catch((err) => newLogger.error({err}, 'doAdulting: error executing session'));
|
||||
return cs;
|
||||
}
|
||||
|
||||
@@ -1,7 +1,4 @@
|
||||
const {
|
||||
TaskName,
|
||||
} = require('./constants.json');
|
||||
|
||||
const {TaskName} = require('./constants.json');
|
||||
const stickyVars = {
|
||||
google: [
|
||||
'GOOGLE_SPEECH_HINTS',
|
||||
@@ -45,12 +42,19 @@ const stickyVars = {
|
||||
'DEEPGRAM_SPEECH_ENDPOINTING',
|
||||
'DEEPGRAM_SPEECH_UTTERANCE_END_MS',
|
||||
'DEEPGRAM_SPEECH_VAD_TURNOFF',
|
||||
'DEEPGRAM_SPEECH_TAG'
|
||||
'DEEPGRAM_SPEECH_TAG',
|
||||
'DEEPGRAM_SPEECH_MODEL_VERSION'
|
||||
],
|
||||
aws: [
|
||||
'AWS_VOCABULARY_NAME',
|
||||
'AWS_VOCABULARY_FILTER_METHOD',
|
||||
'AWS_VOCABULARY_FILTER_NAME'
|
||||
'AWS_VOCABULARY_FILTER_NAME',
|
||||
'AWS_LANGUAGE_MODEL_NAME',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_REGION',
|
||||
'AWS_SECURITY_TOKEN',
|
||||
'AWS_PII_ENTITY_TYPES',
|
||||
],
|
||||
nuance: [
|
||||
'NUANCE_ACCESS_TOKEN',
|
||||
@@ -99,6 +103,12 @@ const stickyVars = {
|
||||
assemblyai: [
|
||||
'ASSEMBLYAI_API_KEY',
|
||||
'ASSEMBLYAI_WORD_BOOST'
|
||||
],
|
||||
speechmatics: [
|
||||
'SPEECHMATICS_API_KEY',
|
||||
'SPEECHMATICS_HOST',
|
||||
'SPEECHMATICS_PATH',
|
||||
'SPEECHMATICS_SPEECH_HINTS',
|
||||
]
|
||||
};
|
||||
|
||||
@@ -141,7 +151,6 @@ const optimalDeepramModels = {
|
||||
tr: ['nova-2', 'nova-2'],
|
||||
uk: ['nova-2', 'nova-2']
|
||||
};
|
||||
|
||||
const selectDefaultDeepgramModel = (task, language) => {
|
||||
if (language in optimalDeepramModels) {
|
||||
const [gather, transcribe] = optimalDeepramModels[language];
|
||||
@@ -150,6 +159,29 @@ const selectDefaultDeepgramModel = (task, language) => {
|
||||
return 'base';
|
||||
};
|
||||
|
||||
const optimalGoogleModels = {
|
||||
'v1' : {
|
||||
'en-IN':['telephony', 'telephony'],
|
||||
'es-DO':['default', 'default'],
|
||||
'es-MX':['default', 'default'],
|
||||
'en-AU':['telephony', 'telephony'],
|
||||
'en-GB':['telephony', 'telephony'],
|
||||
'en-NZ':['telephony', 'telephony']
|
||||
},
|
||||
'v2' : {
|
||||
'en-IN':['telephony', 'long']
|
||||
}
|
||||
};
|
||||
const selectDefaultGoogleModel = (task, language, version) => {
|
||||
const useV2 = version === 'v2';
|
||||
if (language in optimalGoogleModels[version]) {
|
||||
const [gather, transcribe] = optimalGoogleModels[version][language];
|
||||
return task.name === TaskName.Gather ? gather : transcribe;
|
||||
}
|
||||
return task.name === TaskName.Gather ?
|
||||
(useV2 ? 'telephony_short' : 'command_and_search') :
|
||||
(useV2 ? 'long' : 'latest_long');
|
||||
};
|
||||
const consolidateTranscripts = (bufferedTranscripts, channel, language, vendor) => {
|
||||
if (bufferedTranscripts.length === 1) return bufferedTranscripts[0];
|
||||
let totalConfidence = 0;
|
||||
@@ -316,8 +348,10 @@ const normalizeIbm = (evt, channel, language) => {
|
||||
|
||||
const normalizeGoogle = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const language_code = evt.language_code || language;
|
||||
|
||||
return {
|
||||
language_code: language,
|
||||
language_code: language_code,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: [evt.alternatives[0]],
|
||||
@@ -376,6 +410,20 @@ const normalizeNuance = (evt, channel, language) => {
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeVerbio = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.is_final,
|
||||
alternatives: evt.alternatives,
|
||||
vendor: {
|
||||
name: 'verbio',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeMicrosoft = (evt, channel, language, punctuation = true) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const nbest = evt.NBest;
|
||||
@@ -407,16 +455,41 @@ const normalizeMicrosoft = (evt, channel, language, punctuation = true) => {
|
||||
|
||||
const normalizeAws = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt[0].is_final,
|
||||
alternatives: evt[0].alternatives,
|
||||
vendor: {
|
||||
name: 'aws',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
const isGrpcPayload = Array.isArray(evt);
|
||||
if (isGrpcPayload) {
|
||||
/* legacy grpc api */
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt[0].is_final,
|
||||
alternatives: evt[0].alternatives,
|
||||
vendor: {
|
||||
name: 'aws',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
}
|
||||
else {
|
||||
/* websocket api */
|
||||
const alternatives = evt.Transcript?.Results[0]?.Alternatives.map((alt) => {
|
||||
const items = alt.Items.filter((item) => item.Type === 'pronunciation' && 'Confidence' in item);
|
||||
const confidence = items.reduce((acc, item) => acc + item.Confidence, 0) / items.length;
|
||||
return {
|
||||
transcript: alt.Transcript,
|
||||
confidence
|
||||
};
|
||||
});
|
||||
return {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: evt.Transcript?.Results[0].IsPartial === false,
|
||||
alternatives,
|
||||
vendor: {
|
||||
name: 'aws',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
}
|
||||
};
|
||||
|
||||
const normalizeAssemblyAi = (evt, channel, language) => {
|
||||
@@ -432,12 +505,37 @@ const normalizeAssemblyAi = (evt, channel, language) => {
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'ASSEMBLYAI',
|
||||
name: 'assemblyai',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeSpeechmatics = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const is_final = evt.message === 'AddTranscript';
|
||||
const words = evt.results?.filter((r) => r.type === 'word') || [];
|
||||
const confidence = words.length > 0 ?
|
||||
words.reduce((acc, word) => acc + word.alternatives[0].confidence, 0) / words.length :
|
||||
0;
|
||||
|
||||
const alternative = {
|
||||
confidence,
|
||||
transcript: evt.metadata?.transcript
|
||||
};
|
||||
const obj = {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final,
|
||||
alternatives: [alternative],
|
||||
vendor: {
|
||||
name: 'speechmatics',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
return obj;
|
||||
};
|
||||
|
||||
module.exports = (logger) => {
|
||||
const normalizeTranscription = (evt, vendor, channel, language, shortUtterance, punctuation) => {
|
||||
|
||||
@@ -463,6 +561,10 @@ module.exports = (logger) => {
|
||||
return normalizeCobalt(evt, channel, language);
|
||||
case 'assemblyai':
|
||||
return normalizeAssemblyAi(evt, channel, language, shortUtterance);
|
||||
case 'verbio':
|
||||
return normalizeVerbio(evt, channel, language);
|
||||
case 'speechmatics':
|
||||
return normalizeSpeechmatics(evt, channel, language);
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
return normalizeCustom(evt, channel, language, vendor);
|
||||
@@ -474,23 +576,13 @@ module.exports = (logger) => {
|
||||
|
||||
const setChannelVarsForStt = (task, sttCredentials, language, rOpts = {}) => {
|
||||
let opts = {};
|
||||
const {enable, voiceMs = 0, mode = -1} = rOpts.vad || {};
|
||||
const vad = {enable, voiceMs, mode};
|
||||
const vendor = rOpts.vendor;
|
||||
|
||||
/* voice activity detection works across vendors */
|
||||
opts = {
|
||||
...opts,
|
||||
...(vad.enable && {START_RECOGNIZING_ON_VAD: 1}),
|
||||
...(vad.enable && vad.voiceMs && {RECOGNIZER_VAD_VOICE_MS: vad.voiceMs}),
|
||||
...(vad.enable && typeof vad.mode === 'number' && {RECOGNIZER_VAD_MODE: vad.mode}),
|
||||
};
|
||||
|
||||
if ('google' === vendor) {
|
||||
const useV2 = rOpts.googleOptions?.serviceVersion === 'v2';
|
||||
const model = task.name === TaskName.Gather ?
|
||||
(useV2 ? 'telephony_short' : 'command_and_search') :
|
||||
(useV2 ? 'long' : 'latest_long');
|
||||
const version = useV2 ? 'v2' : 'v1';
|
||||
let {model} = rOpts;
|
||||
model = model || selectDefaultGoogleModel(task, language, version);
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials && {GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(sttCredentials.credentials)}),
|
||||
@@ -546,16 +638,29 @@ module.exports = (logger) => {
|
||||
};
|
||||
}
|
||||
else if (['aws', 'polly'].includes(vendor)) {
|
||||
const {awsOptions = {}} = rOpts;
|
||||
const vocabularyName = awsOptions.vocabularyName || rOpts.vocabularyName;
|
||||
const vocabularyFilterName = awsOptions.vocabularyFilterName || rOpts.vocabularyFilterName;
|
||||
const filterMethod = awsOptions.vocabularyFilterMethod || rOpts.filterMethod;
|
||||
opts = {
|
||||
...opts,
|
||||
...(rOpts.vocabularyName && {AWS_VOCABULARY_NAME: rOpts.vocabularyName}),
|
||||
...(rOpts.vocabularyFilterName && {AWS_VOCABULARY_FILTER_NAME: rOpts.vocabularyFilterName}),
|
||||
...(rOpts.filterMethod && {AWS_VOCABULARY_FILTER_METHOD: rOpts.filterMethod}),
|
||||
...(vocabularyName && {AWS_VOCABULARY_NAME: vocabularyName}),
|
||||
...(vocabularyFilterName && {AWS_VOCABULARY_FILTER_NAME: vocabularyFilterName}),
|
||||
...(filterMethod && {AWS_VOCABULARY_FILTER_METHOD: filterMethod}),
|
||||
...(sttCredentials && {
|
||||
AWS_ACCESS_KEY_ID: sttCredentials.accessKeyId,
|
||||
AWS_SECRET_ACCESS_KEY: sttCredentials.secretAccessKey,
|
||||
AWS_REGION: sttCredentials.region
|
||||
AWS_REGION: sttCredentials.region,
|
||||
AWS_SECURITY_TOKEN: sttCredentials.securityToken
|
||||
}),
|
||||
...(awsOptions.accessKey && {AWS_ACCESS_KEY_ID: awsOptions.accessKey}),
|
||||
...(awsOptions.secretKey && {AWS_SECRET_ACCESS_KEY: awsOptions.secretKey}),
|
||||
...(awsOptions.region && {AWS_REGION: awsOptions.region}),
|
||||
...(awsOptions.securityToken && {AWS_SECURITY_TOKEN: awsOptions.securityToken}),
|
||||
...(awsOptions.languageModelName && {AWS_LANGUAGE_MODEL_NAME: awsOptions.languageModelName}),
|
||||
...(awsOptions.piiEntityTypes?.length && {AWS_PII_ENTITY_TYPES: awsOptions.piiEntityTypes.join(',')}),
|
||||
...(awsOptions.piiIdentifyEntities && {AWS_PII_IDENTIFY_ENTITIES: true}),
|
||||
...(awsOptions.languageModelName && {AWS_LANGUAGE_MODEL_NAME: awsOptions.languageModelName}),
|
||||
};
|
||||
}
|
||||
else if ('microsoft' === vendor) {
|
||||
@@ -583,6 +688,8 @@ module.exports = (logger) => {
|
||||
{AZURE_SPEECH_SEGMENTATION_SILENCE_TIMEOUT_MS: azureOptions.speechSegmentationSilenceTimeoutMs}),
|
||||
...(azureOptions.languageIdMode &&
|
||||
{AZURE_LANGUAGE_ID_MODE: azureOptions.languageIdMode}),
|
||||
...(azureOptions.postProcessing &&
|
||||
{AZURE_POST_PROCESSING_OPTION: azureOptions.postProcessing}),
|
||||
...(sttCredentials && {
|
||||
...(sttCredentials.api_key && {AZURE_SUBSCRIPTION_KEY: sttCredentials.api_key}),
|
||||
...(sttCredentials.region && {AZURE_REGION: sttCredentials.region}),
|
||||
@@ -690,13 +797,17 @@ 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) &&
|
||||
{DEEPGRAM_SPEECH_VAD_TURNOFF: deepgramOptions.vadTurnoff},
|
||||
...(deepgramOptions.tag) &&
|
||||
{DEEPGRAM_SPEECH_TAG: deepgramOptions.tag}
|
||||
{DEEPGRAM_SPEECH_TAG: deepgramOptions.tag},
|
||||
...(deepgramOptions.version) &&
|
||||
{DEEPGRAM_SPEECH_MODEL_VERSION: deepgramOptions.version}
|
||||
};
|
||||
}
|
||||
else if ('soniox' === vendor) {
|
||||
@@ -795,7 +906,8 @@ module.exports = (logger) => {
|
||||
...(cobaltOptions.enableConfusionNetwork && {COBALT_ENABLE_CONFUSION_NETWORK: 1}),
|
||||
...(cobaltOptions.compiledContextData && {COBALT_COMPILED_CONTEXT_DATA: cobaltOptions.compiledContextData}),
|
||||
};
|
||||
} else if ('assemblyai' === vendor) {
|
||||
}
|
||||
else if ('assemblyai' === vendor) {
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.api_key) &&
|
||||
@@ -804,8 +916,37 @@ module.exports = (logger) => {
|
||||
{ASSEMBLYAI_WORD_BOOST: JSON.stringify(rOpts.hints)})
|
||||
};
|
||||
}
|
||||
else if ('verbio' === vendor) {
|
||||
const {verbioOptions = {}} = rOpts;
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.access_token && { VERBIO_ACCESS_TOKEN: sttCredentials.access_token}),
|
||||
...(sttCredentials.engine_version && {VERBIO_ENGINE_VERSION: sttCredentials.engine_version}),
|
||||
...(language && {VERBIO_LANGUAGE: language}),
|
||||
...(verbioOptions.enable_formatting && {VERBIO_ENABLE_FORMATTING: verbioOptions.enable_formatting}),
|
||||
...(verbioOptions.enable_diarization && {VERBIO_ENABLE_DIARIZATION: verbioOptions.enable_diarization}),
|
||||
...(verbioOptions.topic && {VERBIO_TOPIC: verbioOptions.topic}),
|
||||
...(verbioOptions.inline_grammar && {VERBIO_INLINE_GRAMMAR: verbioOptions.inline_grammar}),
|
||||
...(verbioOptions.grammar_uri && {VERBIO_GRAMMAR_URI: verbioOptions.grammar_uri}),
|
||||
...(verbioOptions.label && {VERBIO_LABEL: verbioOptions.label}),
|
||||
...(verbioOptions.recognition_timeout && {VERBIO_RECOGNITION_TIMEOUT: verbioOptions.recognition_timeout}),
|
||||
...(verbioOptions.speech_complete_timeout &&
|
||||
{VERBIO_SPEECH_COMPLETE_TIMEOUT: verbioOptions.speech_complete_timeout}),
|
||||
...(verbioOptions.speech_incomplete_timeout &&
|
||||
{VERBIO_SPEECH_INCOMPLETE_TIMEOUT: verbioOptions.speech_incomplete_timeout}),
|
||||
};
|
||||
}
|
||||
else if ('speechmatics' === vendor) {
|
||||
opts = {
|
||||
...opts,
|
||||
...(sttCredentials.api_key) && {SPEECHMATICS_API_KEY: sttCredentials.api_key},
|
||||
...(sttCredentials.speechmatics_stt_uri) && {SPEECHMATICS_HOST: sttCredentials.speechmatics_stt_uri},
|
||||
...(rOpts.hints?.length > 0 && {SPEECHMATICS_SPEECH_HINTS: rOpts.hints.join(',')}),
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
let {options = {}} = rOpts;
|
||||
let {options = {}} = rOpts.customOptions || {};
|
||||
const {sampleRate} = rOpts.customOptions || {};
|
||||
const {auth_token, custom_stt_url} = sttCredentials;
|
||||
options = {
|
||||
...options,
|
||||
@@ -813,14 +954,15 @@ module.exports = (logger) => {
|
||||
{hints: rOpts.hints}),
|
||||
...(rOpts.hints?.length > 0 && typeof rOpts.hints[0] === 'object' &&
|
||||
{hints: JSON.stringify(rOpts.hints)}),
|
||||
...(typeof rOpts.hintsBoost === 'number' && {hintsBoost: rOpts.hintsBoost})
|
||||
...(typeof rOpts.hintsBoost === 'number' && {hintsBoost: rOpts.hintsBoost}),
|
||||
...(task.cs?.callSid && {callSid: task.cs.callSid})
|
||||
};
|
||||
|
||||
opts = {
|
||||
...opts,
|
||||
...(auth_token && {JAMBONZ_STT_API_KEY: auth_token}),
|
||||
JAMBONZ_STT_URL: custom_stt_url,
|
||||
...(Object.keys(options).length > 0 && {JAMBONZ_STT_OPTIONS: JSON.stringify(options)}),
|
||||
...(sampleRate && {JAMBONZ_STT_SAMPLING: sampleRate})
|
||||
};
|
||||
}
|
||||
|
||||
@@ -870,6 +1012,6 @@ module.exports = (logger) => {
|
||||
setChannelVarsForStt,
|
||||
setSpeechCredentialsAtRuntime,
|
||||
compileSonioxTranscripts,
|
||||
consolidateTranscripts
|
||||
consolidateTranscripts,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -44,7 +44,7 @@ class WsRequestor extends BaseRequestor {
|
||||
async request(type, hook, params, httpHeaders = {}) {
|
||||
assert(HookMsgTypes.includes(type));
|
||||
const url = hook.url || hook;
|
||||
const wantsAck = !['call:status', 'verb:status', 'jambonz:error'].includes(type);
|
||||
const wantsAck = !['call:status', 'verb:status', 'jambonz:error', 'llm:event', 'llm:tool-call'].includes(type);
|
||||
|
||||
if (this.maliciousClient) {
|
||||
this.logger.info({url: this.url}, 'WsRequestor:request - discarding msg to malicious client');
|
||||
@@ -56,6 +56,12 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
|
||||
if (type === 'session:new') this.call_sid = params.callSid;
|
||||
if (type === 'session:reconnect') {
|
||||
this._reconnectPromise = new Promise((resolve, reject) => {
|
||||
this._reconnectResolve = resolve;
|
||||
this._reconnectReject = reject;
|
||||
});
|
||||
}
|
||||
|
||||
/* if we have an absolute url, and it is http then do a standard webhook */
|
||||
if (this._isAbsoluteUrl(url) && url.startsWith('http')) {
|
||||
@@ -71,20 +77,23 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
|
||||
/* connect if necessary */
|
||||
const queueMsg = () => {
|
||||
this.logger.debug(
|
||||
`WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`);
|
||||
if (wantsAck) {
|
||||
const p = new Promise((resolve, reject) => {
|
||||
this.queuedMsg.push({type, hook, params, httpHeaders, promise: {resolve, reject}});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
else {
|
||||
this.queuedMsg.push({type, hook, params, httpHeaders});
|
||||
}
|
||||
return;
|
||||
};
|
||||
if (!this.ws) {
|
||||
if (this.connectInProgress) {
|
||||
this.logger.debug(
|
||||
`WsRequestor:request(${this.id}) - queueing ${type} message since we are connecting`);
|
||||
if (wantsAck) {
|
||||
const p = new Promise((resolve, reject) => {
|
||||
this.queuedMsg.push({type, hook, params, httpHeaders, promise: {resolve, reject}});
|
||||
});
|
||||
return p;
|
||||
}
|
||||
else {
|
||||
this.queuedMsg.push({type, hook, params, httpHeaders});
|
||||
}
|
||||
return;
|
||||
return queueMsg();
|
||||
}
|
||||
this.connectInProgress = true;
|
||||
this.logger.debug(`WsRequestor:request(${this.id}) - connecting since we do not have a connection for ${type}`);
|
||||
@@ -102,6 +111,10 @@ class WsRequestor extends BaseRequestor {
|
||||
return Promise.reject(err);
|
||||
}
|
||||
}
|
||||
// If jambonz wait for ack from reconnect, queue the msg until reconnect is acked
|
||||
if (type !== 'session:reconnect' && this._reconnectPromise) {
|
||||
return queueMsg();
|
||||
}
|
||||
assert(this.ws);
|
||||
|
||||
/* prepare and send message */
|
||||
@@ -119,7 +132,7 @@ class WsRequestor extends BaseRequestor {
|
||||
type,
|
||||
msgid,
|
||||
call_sid: this.call_sid,
|
||||
hook: ['verb:hook', 'session:redirect'].includes(type) ? url : undefined,
|
||||
hook: ['verb:hook', 'session:redirect', 'llm:event', 'llm:tool-call'].includes(type) ? url : undefined,
|
||||
data: {...payload},
|
||||
...b3
|
||||
};
|
||||
@@ -139,6 +152,18 @@ class WsRequestor extends BaseRequestor {
|
||||
}
|
||||
};
|
||||
|
||||
const rejectQueuedMsgs = (err) => {
|
||||
if (this.queuedMsg.length > 0) {
|
||||
for (const {promise} of this.queuedMsg) {
|
||||
this.logger.debug(`WsRequestor:request - preparing queued ${type} for rejectQueuedMsgs`);
|
||||
if (promise) {
|
||||
promise.reject(err);
|
||||
}
|
||||
}
|
||||
this.queuedMsg.length = 0;
|
||||
}
|
||||
};
|
||||
|
||||
//this.logger.debug({obj}, `websocket: sending (${url})`);
|
||||
|
||||
/* special case: reconnecting before we received ack to session:new */
|
||||
@@ -179,16 +204,37 @@ class WsRequestor extends BaseRequestor {
|
||||
this.logger.debug({response}, `WsRequestor:request ${url} succeeded in ${rtt}ms`);
|
||||
this.stats.histogram('app.hook.ws_response_time', rtt, ['hook_type:app']);
|
||||
resolve(response);
|
||||
if (this._reconnectResolve) {
|
||||
this._reconnectResolve();
|
||||
}
|
||||
},
|
||||
failure: (err) => {
|
||||
if (this._reconnectReject) {
|
||||
this._reconnectReject(err);
|
||||
}
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
/* send the message */
|
||||
this.ws.send(JSON.stringify(obj), () => {
|
||||
this.ws.send(JSON.stringify(obj), async() => {
|
||||
this.logger.debug({obj}, `WsRequestor:request websocket: sent (${url})`);
|
||||
// If session:reconnect is waiting for ack, hold here until ack to send queuedMsgs
|
||||
if (this._reconnectPromise) {
|
||||
try {
|
||||
await this._reconnectPromise;
|
||||
} catch (err) {
|
||||
// bad thing happened to session:recconnect
|
||||
rejectQueuedMsgs(err);
|
||||
this.emit('reconnect-error');
|
||||
return;
|
||||
} finally {
|
||||
this._reconnectPromise = null;
|
||||
this._reconnectResolve = null;
|
||||
this._reconnectReject = null;
|
||||
}
|
||||
}
|
||||
sendQueuedMsgs();
|
||||
});
|
||||
});
|
||||
@@ -346,8 +392,9 @@ class WsRequestor extends BaseRequestor {
|
||||
/* messages must be JSON format */
|
||||
try {
|
||||
const obj = JSON.parse(content);
|
||||
this.logger.debug({obj}, 'WsRequestor:_onMessage - received message');
|
||||
//const {type, msgid, command, call_sid = this.call_sid, queueCommand = false, data} = obj;
|
||||
const {type, msgid, command, queueCommand = false, data} = obj;
|
||||
const {type, msgid, command, queueCommand = false, tool_call_id, data} = obj;
|
||||
const call_sid = obj.callSid || this.call_sid;
|
||||
|
||||
//this.logger.debug({obj}, 'WsRequestor:request websocket: received');
|
||||
@@ -361,8 +408,8 @@ class WsRequestor extends BaseRequestor {
|
||||
|
||||
case 'command':
|
||||
assert.ok(command, 'command property not supplied');
|
||||
assert.ok(data, 'data property not supplied');
|
||||
this._recvCommand(msgid, command, call_sid, queueCommand, data);
|
||||
assert.ok(data || command === 'llm:tool-output', 'data property not supplied');
|
||||
this._recvCommand(msgid, command, call_sid, queueCommand, tool_call_id, data);
|
||||
break;
|
||||
|
||||
default:
|
||||
@@ -386,10 +433,10 @@ class WsRequestor extends BaseRequestor {
|
||||
success && success(data);
|
||||
}
|
||||
|
||||
_recvCommand(msgid, command, call_sid, queueCommand, data) {
|
||||
_recvCommand(msgid, command, call_sid, queueCommand, tool_call_id, data) {
|
||||
// TODO: validate command
|
||||
this.logger.debug({msgid, command, call_sid, queueCommand, data}, 'received command');
|
||||
this.emit('command', {msgid, command, call_sid, queueCommand, data});
|
||||
this.emit('command', {msgid, command, call_sid, queueCommand, tool_call_id, data});
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
5805
package-lock.json
generated
5805
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "jambonz-feature-server",
|
||||
"version": "0.9.0",
|
||||
"version": "0.9.2",
|
||||
"main": "app.js",
|
||||
"engines": {
|
||||
"node": ">= 18.x"
|
||||
@@ -27,14 +27,14 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-auto-scaling": "^3.549.0",
|
||||
"@aws-sdk/client-sns": "^3.549.0",
|
||||
"@jambonz/db-helpers": "^0.9.3",
|
||||
"@jambonz/db-helpers": "^0.9.6",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.8",
|
||||
"@jambonz/speech-utils": "^0.0.50",
|
||||
"@jambonz/stats-collector": "^0.1.9",
|
||||
"@jambonz/time-series": "^0.2.8",
|
||||
"@jambonz/verb-specifications": "^0.0.69",
|
||||
"@jambonz/speech-utils": "^0.1.20",
|
||||
"@jambonz/stats-collector": "^0.1.10",
|
||||
"@jambonz/time-series": "^0.2.9",
|
||||
"@jambonz/verb-specifications": "^0.0.83",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
|
||||
@@ -47,24 +47,23 @@
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^3.0.40",
|
||||
"drachtio-srf": "^4.5.31",
|
||||
"drachtio-fsmrf": "^3.0.45",
|
||||
"drachtio-srf": "^4.5.35",
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"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.2",
|
||||
"short-uuid": "^4.2.2",
|
||||
"short-uuid": "^5.1.0",
|
||||
"sinon": "^17.0.1",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"undici": "^6.11.1",
|
||||
"undici": "^6.20.0",
|
||||
"uuid-random": "^1.3.2",
|
||||
"verify-aws-sns-signature": "^0.1.0",
|
||||
"ws": "^8.16.0",
|
||||
"ws": "^8.18.0",
|
||||
"xml2js": "^0.6.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
/* SQLEditor (MySQL (2))*/
|
||||
|
||||
SET FOREIGN_KEY_CHECKS=0;
|
||||
|
||||
DROP TABLE IF EXISTS account_static_ips;
|
||||
@@ -53,6 +54,8 @@ DROP TABLE IF EXISTS signup_history;
|
||||
|
||||
DROP TABLE IF EXISTS smpp_addresses;
|
||||
|
||||
DROP TABLE IF EXISTS google_custom_voices;
|
||||
|
||||
DROP TABLE IF EXISTS speech_credentials;
|
||||
|
||||
DROP TABLE IF EXISTS system_information;
|
||||
@@ -136,6 +139,9 @@ account_sid CHAR(36) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
username VARCHAR(64),
|
||||
password VARCHAR(1024),
|
||||
allow_direct_app_calling BOOLEAN NOT NULL DEFAULT 1,
|
||||
allow_direct_queue_calling BOOLEAN NOT NULL DEFAULT 1,
|
||||
allow_direct_user_calling BOOLEAN NOT NULL DEFAULT 1,
|
||||
PRIMARY KEY (client_sid)
|
||||
);
|
||||
|
||||
@@ -338,11 +344,23 @@ label VARCHAR(64),
|
||||
PRIMARY KEY (speech_credential_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE google_custom_voices
|
||||
(
|
||||
google_custom_voice_sid CHAR(36) NOT NULL UNIQUE ,
|
||||
speech_credential_sid CHAR(36) NOT NULL,
|
||||
model VARCHAR(512) NOT NULL,
|
||||
reported_usage ENUM('REPORTED_USAGE_UNSPECIFIED','REALTIME','OFFLINE') DEFAULT 'REALTIME',
|
||||
name VARCHAR(64) NOT NULL,
|
||||
PRIMARY KEY (google_custom_voice_sid)
|
||||
);
|
||||
|
||||
CREATE TABLE system_information
|
||||
(
|
||||
domain_name VARCHAR(255),
|
||||
sip_domain_name VARCHAR(255),
|
||||
monitoring_domain_name VARCHAR(255)
|
||||
monitoring_domain_name VARCHAR(255),
|
||||
private_network_cidr VARCHAR(8192),
|
||||
log_level ENUM('info', 'debug') NOT NULL DEFAULT 'info'
|
||||
);
|
||||
|
||||
CREATE TABLE users
|
||||
@@ -437,11 +455,14 @@ CREATE TABLE sip_gateways
|
||||
sip_gateway_sid CHAR(36),
|
||||
ipv4 VARCHAR(128) NOT NULL COMMENT 'ip address or DNS name of the gateway. For gateways providing inbound calling service, ip address is required.',
|
||||
netmask INTEGER NOT NULL DEFAULT 32,
|
||||
port INTEGER NOT NULL DEFAULT 5060 COMMENT 'sip signaling port',
|
||||
port INTEGER COMMENT 'sip signaling port',
|
||||
inbound BOOLEAN NOT NULL COMMENT 'if true, whitelist this IP to allow inbound calls from the gateway',
|
||||
outbound BOOLEAN NOT NULL COMMENT 'if true, include in least-cost routing when placing calls to the PSTN',
|
||||
voip_carrier_sid CHAR(36) NOT NULL,
|
||||
is_active BOOLEAN NOT NULL DEFAULT 1,
|
||||
send_options_ping BOOLEAN NOT NULL DEFAULT 0,
|
||||
use_sips_scheme BOOLEAN NOT NULL DEFAULT 0,
|
||||
pad_crypto BOOLEAN NOT NULL DEFAULT 0,
|
||||
protocol ENUM('udp','tcp','tls', 'tls/srtp') DEFAULT 'udp' COMMENT 'Outbound call protocol',
|
||||
PRIMARY KEY (sip_gateway_sid)
|
||||
) COMMENT='A whitelisted sip gateway used for origination/termination';
|
||||
@@ -478,11 +499,19 @@ messaging_hook_sid CHAR(36) COMMENT 'webhook to call for inbound SMS/MMS ',
|
||||
app_json TEXT,
|
||||
speech_synthesis_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
||||
speech_synthesis_language VARCHAR(12) NOT NULL DEFAULT 'en-US',
|
||||
speech_synthesis_voice VARCHAR(64),
|
||||
speech_synthesis_voice VARCHAR(256),
|
||||
speech_synthesis_label VARCHAR(64),
|
||||
speech_recognizer_vendor VARCHAR(64) NOT NULL DEFAULT 'google',
|
||||
speech_recognizer_language VARCHAR(64) NOT NULL DEFAULT 'en-US',
|
||||
speech_recognizer_label VARCHAR(64),
|
||||
use_for_fallback_speech BOOLEAN DEFAULT false,
|
||||
fallback_speech_synthesis_vendor VARCHAR(64),
|
||||
fallback_speech_synthesis_language VARCHAR(12),
|
||||
fallback_speech_synthesis_voice VARCHAR(256),
|
||||
fallback_speech_synthesis_label VARCHAR(64),
|
||||
fallback_speech_recognizer_vendor VARCHAR(64),
|
||||
fallback_speech_recognizer_language VARCHAR(64),
|
||||
fallback_speech_recognizer_label VARCHAR(64),
|
||||
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
record_all_calls BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (application_sid)
|
||||
@@ -525,6 +554,7 @@ siprec_hook_sid CHAR(36),
|
||||
record_all_calls BOOLEAN NOT NULL DEFAULT false,
|
||||
record_format VARCHAR(16) NOT NULL DEFAULT 'mp3',
|
||||
bucket_credential VARCHAR(8192) COMMENT 'credential used to authenticate with storage service',
|
||||
enable_debug_log BOOLEAN NOT NULL DEFAULT false,
|
||||
PRIMARY KEY (account_sid)
|
||||
) COMMENT='An enterprise that uses the platform for comm services';
|
||||
|
||||
@@ -619,6 +649,10 @@ ALTER TABLE speech_credentials ADD FOREIGN KEY service_provider_sid_idxfk_5 (ser
|
||||
CREATE INDEX account_sid_idx ON speech_credentials (account_sid);
|
||||
ALTER TABLE speech_credentials ADD FOREIGN KEY account_sid_idxfk_8 (account_sid) REFERENCES accounts (account_sid);
|
||||
|
||||
CREATE INDEX google_custom_voice_sid_idx ON google_custom_voices (google_custom_voice_sid);
|
||||
CREATE INDEX speech_credential_sid_idx ON google_custom_voices (speech_credential_sid);
|
||||
ALTER TABLE google_custom_voices ADD FOREIGN KEY speech_credential_sid_idxfk (speech_credential_sid) REFERENCES speech_credentials (speech_credential_sid) ON DELETE CASCADE;
|
||||
|
||||
CREATE INDEX user_sid_idx ON users (user_sid);
|
||||
CREATE INDEX email_idx ON users (email);
|
||||
CREATE INDEX phone_idx ON users (phone);
|
||||
@@ -704,4 +738,5 @@ ALTER TABLE accounts ADD FOREIGN KEY queue_event_hook_sid_idxfk (queue_event_hoo
|
||||
ALTER TABLE accounts ADD FOREIGN KEY device_calling_application_sid_idxfk (device_calling_application_sid) REFERENCES applications (application_sid);
|
||||
|
||||
ALTER TABLE accounts ADD FOREIGN KEY siprec_hook_sid_idxfk (siprec_hook_sid) REFERENCES applications (application_sid);
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
|
||||
SET FOREIGN_KEY_CHECKS=1;
|
||||
|
||||
@@ -57,7 +57,7 @@ services:
|
||||
condition: service_healthy
|
||||
|
||||
freeswitch:
|
||||
image: drachtio/drachtio-freeswitch-mrf:0.7.3
|
||||
image: drachtio/drachtio-freeswitch-mrf:latest
|
||||
restart: always
|
||||
command: freeswitch --rtp-range-start 20000 --rtp-range-end 20100
|
||||
environment:
|
||||
|
||||
Reference in New Issue
Block a user