mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-08 04:52:35 +00:00
Compare commits
46 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3b4e0bcd60 | ||
|
|
610c9af274 | ||
|
|
c0a35af591 | ||
|
|
9585018147 | ||
|
|
d7884a837a | ||
|
|
ca0bf36815 | ||
|
|
6b68d32e2c | ||
|
|
8217a76697 | ||
|
|
5c8237b382 | ||
|
|
4ff5c845de | ||
|
|
78ebd08490 | ||
|
|
8b18532f31 | ||
|
|
e4bb00b382 | ||
|
|
14295dcebc | ||
|
|
4d68c179ea | ||
|
|
6205959f53 | ||
|
|
ed92cb2632 | ||
|
|
3098e04ed6 | ||
|
|
7e2fe72b6c | ||
|
|
c2666b7a09 | ||
|
|
9d54ca8116 | ||
|
|
472f4f4532 | ||
|
|
63899d0091 | ||
|
|
31e6997746 | ||
|
|
15b583ef2c | ||
|
|
0bf2013934 | ||
|
|
182c310191 | ||
|
|
4e74bab728 | ||
|
|
9bbc76df12 | ||
|
|
87195b6444 | ||
|
|
eb5e6fa515 | ||
|
|
305facb03b | ||
|
|
d310ba0ed1 | ||
|
|
77f0fc85a3 | ||
|
|
c708b7d007 | ||
|
|
343b382373 | ||
|
|
0a541e089d | ||
|
|
d910981b1a | ||
|
|
3f2744f032 | ||
|
|
fcaf2e59e7 | ||
|
|
ee846b283d | ||
|
|
acdb8695a0 | ||
|
|
f33f197e8d | ||
|
|
9c437ab687 | ||
|
|
1873694784 | ||
|
|
d36e6b4c22 |
95
app.js
95
app.js
@@ -27,8 +27,61 @@ const pino = require('pino');
|
||||
const logger = pino(opts, pino.destination({sync: false}));
|
||||
const {LifeCycleEvents, FS_UUID_SET_NAME, SystemState, FEATURE_SERVER} = require('./lib/utils/constants');
|
||||
const installSrfLocals = require('./lib/utils/install-srf-locals');
|
||||
installSrfLocals(srf, logger);
|
||||
const createHttpListener = require('./lib/utils/http-listener');
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
|
||||
logger.on('level-change', (lvl, _val, prevLvl, _prevVal, instance) => {
|
||||
if (logger !== instance) {
|
||||
return;
|
||||
}
|
||||
logger.info('system log level %s was changed to %s', prevLvl, lvl);
|
||||
});
|
||||
|
||||
// Install the srf locals
|
||||
installSrfLocals(srf, logger, {
|
||||
onFreeswitchConnect: (wraper) => {
|
||||
// Only connect to drachtio if freeswitch is connected
|
||||
logger.info(`connected to freeswitch at ${wraper.ms.address}, start drachtio server`);
|
||||
if (DRACHTIO_HOST) {
|
||||
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
||||
srf.locals.localSipAddress = `${arr[2]}`;
|
||||
logger.info(`connected to drachtio listening on ${hp}, local sip address is ${srf.locals.localSipAddress}`);
|
||||
});
|
||||
}
|
||||
else {
|
||||
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
|
||||
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
|
||||
}
|
||||
// Start Http server
|
||||
createHttpListener(logger, srf)
|
||||
.then(({server, app}) => {
|
||||
httpServer = server;
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
return {server, app};
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Error creating http listener');
|
||||
});
|
||||
},
|
||||
onFreeswitchDisconnect: (wraper) => {
|
||||
// check if all freeswitch connections are lost, disconnect drachtio server
|
||||
logger.info(`lost connection to freeswitch at ${wraper.ms.address}`);
|
||||
const ms = srf.locals.getFreeswitch();
|
||||
if (!ms) {
|
||||
logger.info('no freeswitch connections, stopping drachtio server');
|
||||
disconnect();
|
||||
}
|
||||
}
|
||||
});
|
||||
if (NODE_ENV === 'test') {
|
||||
srf.on('error', (err) => {
|
||||
logger.info(err, 'Error connecting to drachtio');
|
||||
});
|
||||
}
|
||||
|
||||
// Init services
|
||||
const writeSystemAlerts = srf.locals?.writeSystemAlerts;
|
||||
if (writeSystemAlerts) {
|
||||
writeSystemAlerts({
|
||||
@@ -54,24 +107,6 @@ const {
|
||||
const InboundCallSession = require('./lib/session/inbound-call-session');
|
||||
const SipRecCallSession = require('./lib/session/siprec-call-session');
|
||||
|
||||
if (DRACHTIO_HOST) {
|
||||
srf.connect({host: DRACHTIO_HOST, port: DRACHTIO_PORT, secret: DRACHTIO_SECRET });
|
||||
srf.on('connect', (err, hp) => {
|
||||
const arr = /^(.*)\/(.*)$/.exec(hp.split(',').pop());
|
||||
srf.locals.localSipAddress = `${arr[2]}`;
|
||||
logger.info(`connected to drachtio listening on ${hp}, local sip address is ${srf.locals.localSipAddress}`);
|
||||
});
|
||||
}
|
||||
else {
|
||||
logger.info(`listening for drachtio requests on port ${DRACHTIO_PORT}`);
|
||||
srf.listen({port: DRACHTIO_PORT, secret: DRACHTIO_SECRET});
|
||||
}
|
||||
if (NODE_ENV === 'test') {
|
||||
srf.on('error', (err) => {
|
||||
logger.info(err, 'Error connecting to drachtio');
|
||||
});
|
||||
}
|
||||
|
||||
srf.use('invite', [
|
||||
initLocals,
|
||||
createRootSpan,
|
||||
@@ -97,27 +132,20 @@ sessionTracker.on('idle', () => {
|
||||
}
|
||||
});
|
||||
const getCount = () => sessionTracker.count;
|
||||
const healthCheck = require('@jambonz/http-health-check');
|
||||
|
||||
let httpServer;
|
||||
|
||||
const createHttpListener = require('./lib/utils/http-listener');
|
||||
createHttpListener(logger, srf)
|
||||
.then(({server, app}) => {
|
||||
httpServer = server;
|
||||
healthCheck({app, logger, path: '/', fn: getCount});
|
||||
return {server, app};
|
||||
})
|
||||
.catch((err) => {
|
||||
logger.error(err, 'Error creating http listener');
|
||||
});
|
||||
|
||||
|
||||
const monInterval = setInterval(async() => {
|
||||
srf.locals.stats.gauge('fs.sip.calls.count', sessionTracker.count);
|
||||
try {
|
||||
const systemInformation = await srf.locals.dbHelpers.lookupSystemInformation();
|
||||
if (systemInformation && systemInformation.log_level) {
|
||||
logger.level = systemInformation.log_level;
|
||||
const envLogLevel = logger.levels.values[JAMBONES_LOGLEVEL.toLowerCase()];
|
||||
const dbLogLevel = logger.levels.values[systemInformation.log_level];
|
||||
const appliedLogLevel = Math.min(envLogLevel, dbLogLevel);
|
||||
if (logger.levelVal !== appliedLogLevel) {
|
||||
logger.level = logger.levels.labels[Math.min(envLogLevel, dbLogLevel)];
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
if (process.env.NODE_ENV === 'test') {
|
||||
@@ -133,6 +161,7 @@ const disconnect = () => {
|
||||
httpServer?.on('close', resolve);
|
||||
httpServer?.close();
|
||||
srf.disconnect();
|
||||
srf.removeAllListeners();
|
||||
srf.locals.mediaservers?.forEach((ms) => ms.disconnect());
|
||||
});
|
||||
};
|
||||
|
||||
@@ -15,6 +15,7 @@ const {
|
||||
JAMBONES_DISABLE_DIRECT_P2P_CALL
|
||||
} = require('./config');
|
||||
const { createJambonzApp } = require('./dynamic-apps');
|
||||
const { decrypt } = require('./utils/encrypt-decrypt');
|
||||
|
||||
module.exports = function(srf, logger) {
|
||||
const {
|
||||
@@ -348,11 +349,10 @@ module.exports = function(srf, logger) {
|
||||
}
|
||||
|
||||
req.locals.application = app2;
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {call_hook, call_status_hook, ...appInfo} = app; // mask sensitive data like user/pass on webhook
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {requestor, notifier, ...loggable} = appInfo;
|
||||
const {requestor, notifier, env_vars, ...loggable} = appInfo;
|
||||
logger.info({app: loggable}, `retrieved application for incoming call to ${req.locals.calledNumber}`);
|
||||
req.locals.callInfo = new CallInfo({
|
||||
req,
|
||||
@@ -417,10 +417,22 @@ module.exports = function(srf, logger) {
|
||||
...(app.fallback_speech_recognizer_language && {fallback_language: app.fallback_speech_recognizer_language})
|
||||
}
|
||||
};
|
||||
let env_vars;
|
||||
try {
|
||||
if (app.env_vars) {
|
||||
const d_env_vars = JSON.parse(decrypt(app.env_vars));
|
||||
logger.info(`Setting env_vars: ${Object.keys(d_env_vars)}`); // Only log the keys not the values
|
||||
env_vars = d_env_vars;
|
||||
}
|
||||
} catch (error) {
|
||||
logger.info('Unable to set env_vars', error);
|
||||
}
|
||||
const params = Object.assign(['POST', 'WS'].includes(app.call_hook.method) ? { sip: req.msg } : {},
|
||||
req.locals.callInfo,
|
||||
{ service_provider_sid: req.locals.service_provider_sid },
|
||||
{ defaults });
|
||||
{ defaults },
|
||||
{ env_vars }
|
||||
);
|
||||
logger.debug({ params }, 'sending initial webhook');
|
||||
const obj = rootSpan.startChildSpan('performAppWebhook');
|
||||
span = obj.span;
|
||||
|
||||
@@ -23,6 +23,7 @@ const HttpRequestor = require('../utils/http-requestor');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const ActionHookDelayProcessor = require('../utils/action-hook-delay');
|
||||
const TtsStreamingBuffer = require('../utils/tts-streaming-buffer');
|
||||
const StickyEventEmitter = require('../utils/sticky-event-emitter');
|
||||
const {parseUri} = require('drachtio-srf');
|
||||
const {
|
||||
JAMBONES_INJECT_CONTENT,
|
||||
@@ -79,6 +80,10 @@ class CallSession extends Emitter {
|
||||
this.callGone = false;
|
||||
this.notifiedComplete = false;
|
||||
this.rootSpan = rootSpan;
|
||||
this.stickyEventEmitter = new StickyEventEmitter();
|
||||
this.stickyEventEmitter.onSuccess = () => {
|
||||
this.taskInProgress = null;
|
||||
};
|
||||
this.backgroundTaskManager = new BackgroundTaskManager({
|
||||
cs: this,
|
||||
logger,
|
||||
@@ -135,6 +140,15 @@ class CallSession extends Emitter {
|
||||
this.requestor.on('handover', handover.bind(this));
|
||||
this.requestor.on('reconnect-error', this._onSessionReconnectError.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
* Currently this is used for openai STT, which has a prompt paramater and
|
||||
* we have an experimental feature where you can send the conversation
|
||||
* history in the prompt
|
||||
*/
|
||||
this.conversationTurns = [];
|
||||
this.on('userSaid', this._onUserSaid.bind(this));
|
||||
this.on('botSaid', this._onBotSaid.bind(this));
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -953,7 +967,7 @@ class CallSession extends Emitter {
|
||||
(type === 'tts' && credential.use_for_tts) ||
|
||||
(type === 'stt' && credential.use_for_stt)
|
||||
)) {
|
||||
this.logger.info(
|
||||
this.logger.debug(
|
||||
`${type}: ${credential.vendor} ${credential.label ? `, label: ${credential.label}` : ''} `);
|
||||
if ('google' === vendor) {
|
||||
if (type === 'tts' && !credential.tts_tested_ok ||
|
||||
@@ -1106,12 +1120,17 @@ class CallSession extends Emitter {
|
||||
};
|
||||
}
|
||||
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 ('openai' === vendor) {
|
||||
return {
|
||||
api_key: credential.api_key,
|
||||
model_id: credential.model_id,
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
@@ -1166,7 +1185,9 @@ class CallSession extends Emitter {
|
||||
const taskNum = ++this.taskIdx;
|
||||
const stackNum = this.stackIdx;
|
||||
const task = this.tasks.shift();
|
||||
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name}`);
|
||||
this.isCurTaskPlay = TaskName.Play === task.name;
|
||||
this.taskInProgress = task;
|
||||
this.logger.info(`CallSession:exec starting task #${stackNum}:${taskNum}: ${task.name} : {task.taskId}`);
|
||||
this._notifyTaskStatus(task, {event: 'starting'});
|
||||
// Register verbhook span wait for end
|
||||
task.on('VerbHookSpanWaitForEnd', ({span}) => {
|
||||
@@ -1240,7 +1261,7 @@ class CallSession extends Emitter {
|
||||
this.logger.info('CallSession:exec all tasks complete');
|
||||
this._stopping = true;
|
||||
this._onTasksDone();
|
||||
this._clearResources();
|
||||
await this._clearResources();
|
||||
|
||||
|
||||
if (!this.isConfirmCallSession && !this.isSmsCallSession) sessionTracker.remove(this.callSid);
|
||||
@@ -1445,7 +1466,7 @@ class CallSession extends Emitter {
|
||||
}
|
||||
if (tasks) {
|
||||
const t = normalizeJambones(this.logger, tasks).map((tdata) => makeTask(this.logger, tdata));
|
||||
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
|
||||
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_lccCallHook new task list');
|
||||
this.replaceApplication(t);
|
||||
if (this.wakeupResolver) {
|
||||
//this.logger.debug({resolution}, 'CallSession:_onCommand - got commands, waking up..');
|
||||
@@ -1712,10 +1733,10 @@ Duration=${duration} `
|
||||
this.currentTask.ep :
|
||||
this.ep;
|
||||
const db = parseDecibels(opts);
|
||||
this.logger.info(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
|
||||
this.logger.debug(`_lccBoostAudioSignal: boosting audio signal by ${db} dB`);
|
||||
const args = [ep.uuid, 'setGain', db];
|
||||
const response = await ep.api('uuid_dub', args);
|
||||
this.logger.info({response}, '_lccBoostAudioSignal: response from freeswitch');
|
||||
this.logger.debug({response}, '_lccBoostAudioSignal: response from freeswitch');
|
||||
}
|
||||
|
||||
async _lccMediaPath(desiredPath) {
|
||||
@@ -1768,7 +1789,6 @@ Duration=${duration} `
|
||||
let res;
|
||||
try {
|
||||
res = await this.ttsStreamingBuffer?.bufferTokens(tokens);
|
||||
this.logger.info({id, res}, 'CallSession:_lccTtsTokens - tts:tokens-result');
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'CallSession:_lccTtsTokens');
|
||||
}
|
||||
@@ -1903,9 +1923,11 @@ Duration=${duration} `
|
||||
this.tasks = tasks;
|
||||
this.taskIdx = 0;
|
||||
this.stackIdx++;
|
||||
this.logger.info({tasks: listTaskNames(tasks)},
|
||||
this.logger.debug({tasks: listTaskNames(tasks)},
|
||||
`CallSession:replaceApplication reset with ${tasks.length} new tasks, stack depth is ${this.stackIdx}`);
|
||||
if (this.currentTask) {
|
||||
this.logger.debug('CallSession:replaceApplication - killing current task ' +
|
||||
this.currentTask?.name + ', taskId: ' + this.currentTask.taskId);
|
||||
this.currentTask.kill(this, KillReason.Replaced);
|
||||
this.currentTask = null;
|
||||
}
|
||||
@@ -1914,11 +1936,15 @@ Duration=${duration} `
|
||||
this.wakeupResolver({reason: 'new tasks'});
|
||||
this.wakeupResolver = null;
|
||||
}
|
||||
if ((!this.currentTask || this.currentTask === undefined) && this.isCurTaskPlay) {
|
||||
this.logger.debug(`CallSession:replaceApplication - emitting uuid_break, taskId: ${this.taskInProgress?.taskId}`);
|
||||
this.stickyEventEmitter.emit('uuid_break', this.taskInProgress);
|
||||
}
|
||||
}
|
||||
|
||||
kill(onBackgroundGatherBargein = false) {
|
||||
if (this.isConfirmCallSession) this.logger.debug('CallSession:kill (ConfirmSession)');
|
||||
else this.logger.info('CallSession:kill');
|
||||
else this.logger.debug('CallSession:kill');
|
||||
this._endVerbHookSpan();
|
||||
if (this.currentTask) {
|
||||
this.currentTask.kill(this);
|
||||
@@ -1983,7 +2009,7 @@ Duration=${duration} `
|
||||
task.synthesizer.label :
|
||||
this.speechSynthesisLabel;
|
||||
|
||||
this.logger.info({vendor, language, voice, label},
|
||||
this.logger.debug({vendor, language, voice, label},
|
||||
'CallSession:_preCacheAudio - precaching audio for future prompt');
|
||||
task._synthesizeWithSpecificVendor(this, this.ep, {vendor, language, voice, label, preCache: true})
|
||||
.catch((err) => this.logger.error(err, 'CallSession:_preCacheAudio - error precaching audio'));
|
||||
@@ -2053,7 +2079,7 @@ Duration=${duration} `
|
||||
}
|
||||
|
||||
async _onCommand({msgid, command, call_sid, queueCommand, tool_call_id, data}) {
|
||||
this.logger.info({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
|
||||
this.logger.debug({msgid, command, queueCommand, data}, 'CallSession:_onCommand - received command');
|
||||
let resolution;
|
||||
switch (command) {
|
||||
case 'redirect':
|
||||
@@ -2062,18 +2088,18 @@ Duration=${duration} `
|
||||
const t = normalizeJambones(this.logger, data)
|
||||
.map((tdata) => makeTask(this.logger, tdata));
|
||||
if (!queueCommand) {
|
||||
this.logger.info({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
|
||||
this.logger.debug({tasks: listTaskNames(t)}, 'CallSession:_onCommand new task list');
|
||||
this.replaceApplication(t);
|
||||
}
|
||||
else if (JAMBONES_INJECT_CONTENT) {
|
||||
if (JAMBONES_EAGERLY_PRE_CACHE_AUDIO) this._preCacheAudio(t);
|
||||
this._injectTasks(t);
|
||||
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
this.logger.debug({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
}
|
||||
else {
|
||||
if (JAMBONES_EAGERLY_PRE_CACHE_AUDIO) this._preCacheAudio(t);
|
||||
this.tasks.push(...t);
|
||||
this.logger.info({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
this.logger.debug({tasks: listTaskNames(this.tasks)}, 'CallSession:_onCommand - updated task list');
|
||||
}
|
||||
resolution = {reason: 'received command, new tasks', queue: queueCommand, command};
|
||||
resolution.command = listTaskNames(t);
|
||||
@@ -2340,10 +2366,32 @@ Duration=${duration} `
|
||||
// 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});
|
||||
/* Codec negotiation issue explanation:
|
||||
*
|
||||
* Problem scenario:
|
||||
* 1. Initial negotiation:
|
||||
* - FreeSWITCH → SBC: offers multiple codecs (PCMU, PCMA, G722)
|
||||
* - SBC → Callee: passes all codecs (PCMU, PCMA, G722)
|
||||
* - Callee → SBC: responds with PCMA (its supported codec)
|
||||
* - SBC → FreeSWITCH: responds with PCMU (after transcoding)
|
||||
*
|
||||
* 2. After endpoint replacement:
|
||||
* - If we only offer PCMU in the new endpoint
|
||||
* - FreeSWITCH → SBC: offers only PCMU
|
||||
* - SBC → Callee: offers only PCMU
|
||||
* - Call fails: Callee rejects since it only supports PCMA
|
||||
*
|
||||
* Solution:
|
||||
* Always have FreeSWITCH offer multiple codecs to the SBC, don't pass remote sdp here to ensure
|
||||
* the SBC can reoffer the same codecs that the callee originally accepted.
|
||||
* This prevents call failures during media renegotiation.
|
||||
*/
|
||||
|
||||
this.ep = await this.ms.createEndpoint();
|
||||
this._configMsEndpoint();
|
||||
|
||||
await this.dlg.modify(this.ep.local.sdp);
|
||||
const sdp = await this.dlg.modify(this.ep.local.sdp);
|
||||
await this.ep.modify(sdp);
|
||||
this.logger.debug('CallSession:replaceEndpoint completed');
|
||||
return this.ep;
|
||||
}
|
||||
@@ -2351,9 +2399,16 @@ Duration=${duration} `
|
||||
/**
|
||||
* Hang up the call and free the media endpoint
|
||||
*/
|
||||
_clearResources() {
|
||||
async _clearResources() {
|
||||
this.stickyEventEmitter.destroy();
|
||||
this.stickyEventEmitter = null;
|
||||
this.taskInProgress = null;
|
||||
for (const resource of [this.dlg, this.ep, this.ep2]) {
|
||||
if (resource && resource.connected) resource.destroy();
|
||||
try {
|
||||
if (resource && resource.connected) await resource.destroy();
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'CallSession:_clearResources - error clearing resources');
|
||||
}
|
||||
}
|
||||
this.dlg = null;
|
||||
this.ep = null;
|
||||
@@ -2567,7 +2622,7 @@ Duration=${duration} `
|
||||
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.logger.debug('CallSession:handleRefer received REFER, get new tasks');
|
||||
this.replaceApplication(tasks);
|
||||
if (this.wakeupResolver) {
|
||||
this.wakeupResolver({reason: 'CallSession: referHook new taks'});
|
||||
@@ -2614,14 +2669,14 @@ Duration=${duration} `
|
||||
if (typeof this.queueEventHookRequestor === 'undefined') {
|
||||
const pp = this._pool.promise();
|
||||
try {
|
||||
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
|
||||
this.logger.debug({accountSid: this.accountSid}, 'performQueueWebhook: looking up account');
|
||||
const [r] = await pp.query(sqlRetrieveQueueEventHook, [this.accountSid]);
|
||||
if (0 === r.length) {
|
||||
this.logger.info({accountSid: this.accountSid}, 'performQueueWebhook: no webhook provisioned');
|
||||
this.queueEventHookRequestor = null;
|
||||
}
|
||||
else {
|
||||
this.logger.info({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
|
||||
this.logger.debug({accountSid: this.accountSid, webhook: r[0]}, 'performQueueWebhook: webhook found');
|
||||
this.queueEventHookRequestor = new HttpRequestor(this.logger, this.accountSid,
|
||||
r[0], this.webhook_secret);
|
||||
this.queueEventHook = r[0];
|
||||
@@ -2635,7 +2690,7 @@ Duration=${duration} `
|
||||
|
||||
/* send webhook */
|
||||
const params = {...obj, ...this.callInfo.toJSON()};
|
||||
this.logger.info({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
|
||||
this.logger.debug({accountSid: this.accountSid, params}, 'performQueueWebhook: sending webhook');
|
||||
this.queueEventHookRequestor.request('queue:status', this.queueEventHook, params)
|
||||
.catch((err) => {
|
||||
this.logger.info({err, accountSid: this.accountSid, obj}, 'Error sending queue notification event');
|
||||
@@ -2770,7 +2825,7 @@ Duration=${duration} `
|
||||
async handleReinviteAfterMediaReleased(req, res) {
|
||||
assert(this.dlg && this.dlg.connected && !this.ep);
|
||||
const sdp = await this.dlg.modify(req.body);
|
||||
this.logger.info({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
|
||||
this.logger.debug({sdp}, 'CallSession:handleReinviteAfterMediaReleased - reinvite to A leg returned sdp');
|
||||
res.send(200, {body: sdp});
|
||||
}
|
||||
|
||||
@@ -2851,7 +2906,7 @@ Duration=${duration} `
|
||||
// if final transcription is received but endpoint is already closed,
|
||||
// freeswitch module will not be able to send the transcription
|
||||
|
||||
this.logger.info('callSession:_configMsEndpoint -' +
|
||||
this.logger.debug('callSession:_configMsEndpoint -' +
|
||||
' transcribe task, wait for some time before destroy');
|
||||
await sleepFor(JAMBONES_TRANSCRIBE_EP_DESTROY_DELAY_MS);
|
||||
}
|
||||
@@ -2905,7 +2960,7 @@ Duration=${duration} `
|
||||
_awaitCommandsOrHangup() {
|
||||
assert(!this.wakeupResolver);
|
||||
return new Promise((resolve, reject) => {
|
||||
this.logger.info('_awaitCommandsOrHangup - waiting...');
|
||||
this.logger.debug('_awaitCommandsOrHangup - waiting...');
|
||||
this.wakeupResolver = resolve;
|
||||
|
||||
if (this._actionHookDelayProcessor) {
|
||||
@@ -2925,7 +2980,7 @@ Duration=${duration} `
|
||||
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.logger.debug('CallSession:_awaitCommandsOrHangup - filler noise started');
|
||||
this.ep.api('uuid_break', this.ep.uuid)
|
||||
.catch((err) => this.logger.info(err, 'Error killing filler noise'));
|
||||
}
|
||||
@@ -2936,7 +2991,7 @@ Duration=${duration} `
|
||||
|
||||
_clearTasks(backgroundGather, evt) {
|
||||
if (this.requestor instanceof WsRequestor && !backgroundGather.cleared) {
|
||||
this.logger.info({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||
this.logger.debug({evt}, 'CallSession:_clearTasks on event from background gather');
|
||||
try {
|
||||
backgroundGather.cleared = true;
|
||||
this.kill(true);
|
||||
@@ -3014,6 +3069,43 @@ Duration=${duration} `
|
||||
this._jambonzHangup('Max Call Duration');
|
||||
this._maxCallDurationTimer = null;
|
||||
}
|
||||
|
||||
_onUserSaid(transcript) {
|
||||
const count = this.conversationTurns.length;
|
||||
if (count === 0 || this.conversationTurns[count - 1].type === 'assistant') {
|
||||
this.conversationTurns.push({
|
||||
type: 'user',
|
||||
text: transcript
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.conversationTurns[count - 1].text += ` ${transcript}`;
|
||||
}
|
||||
}
|
||||
|
||||
_onBotSaid(transcript) {
|
||||
const count = this.conversationTurns.length;
|
||||
if (count === 0 || this.conversationTurns[count - 1].type === 'user') {
|
||||
this.conversationTurns.push({
|
||||
type: 'assistant',
|
||||
text: transcript
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.conversationTurns[count - 1].text += ` ${transcript}`;
|
||||
}
|
||||
}
|
||||
|
||||
getFormattedConversation(numTurns) {
|
||||
const turns = this.conversationTurns.slice(-numTurns);
|
||||
if (turns.length === 0) return null;
|
||||
return turns.map((t) => {
|
||||
if (t.type === 'user') {
|
||||
return `user: ${t.text}`;
|
||||
}
|
||||
return `assistant: ${t.text}`;
|
||||
}).join('\n');
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = CallSession;
|
||||
|
||||
@@ -63,7 +63,7 @@ class RestCallSession extends CallSession {
|
||||
this.callInfo.callTerminationBy = terminatedBy;
|
||||
const duration = moment().diff(this.dlg.connectTime, 'seconds');
|
||||
this.emit('callStatusChange', {callStatus: CallStatus.Completed, duration});
|
||||
this.logger.debug(`RestCallSession: called party hung up by ${terminatedBy}`);
|
||||
this.logger.info(`RestCallSession: called party hung up by ${terminatedBy}`);
|
||||
this._callReleased();
|
||||
}
|
||||
|
||||
|
||||
@@ -550,7 +550,8 @@ class TaskDial extends Task {
|
||||
proxy: `sip:${sbcAddress}`,
|
||||
callingNumber: this.callerId || fromUri.user,
|
||||
...(this.callerName && {callingName: this.callerName}),
|
||||
opusFirst: isOpusFirst(this.cs.ep.remote.sdp)
|
||||
opusFirst: isOpusFirst(this.cs.ep.remote.sdp),
|
||||
isVideoCall: this.cs.ep.remote.sdp.includes('m=video')
|
||||
};
|
||||
|
||||
const t = this.target.find((t) => t.type === 'teams');
|
||||
|
||||
@@ -12,6 +12,7 @@ const {
|
||||
JambonzTranscriptionEvents,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VadDetection,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
@@ -83,6 +84,7 @@ class TaskGather extends SttTask {
|
||||
this._bufferedTranscripts = [];
|
||||
this.partialTranscriptsCount = 0;
|
||||
this.bugname_prefix = 'gather_';
|
||||
|
||||
}
|
||||
|
||||
get name() { return TaskName.Gather; }
|
||||
@@ -110,6 +112,12 @@ class TaskGather extends SttTask {
|
||||
return this.fillerNoise.startDelaySecs;
|
||||
}
|
||||
|
||||
get isStreamingTts() { return this.sayTask && this.sayTask.isStreamingTts; }
|
||||
|
||||
getTtsVendorData() {
|
||||
if (this.sayTask) return this.sayTask.getTtsVendorData(this.cs);
|
||||
}
|
||||
|
||||
get summary() {
|
||||
let s = `${this.name}{`;
|
||||
if (this.input.length === 2) s += 'inputs=[speech,digits],';
|
||||
@@ -233,6 +241,7 @@ class TaskGather extends SttTask {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.sayTask.summary}`);
|
||||
const process = () => {
|
||||
this.logger.debug('Gather: nested say task completed');
|
||||
this.playComplete = true;
|
||||
if (!this.listenDuringPrompt) {
|
||||
startDtmfListener();
|
||||
}
|
||||
@@ -263,6 +272,7 @@ class TaskGather extends SttTask {
|
||||
const {span, ctx} = this.startChildSpan(`nested:${this.playTask.summary}`);
|
||||
const process = () => {
|
||||
this.logger.debug('Gather: nested play task completed');
|
||||
this.playComplete = true;
|
||||
if (!this.listenDuringPrompt) {
|
||||
startDtmfListener();
|
||||
}
|
||||
@@ -553,6 +563,31 @@ class TaskGather extends SttTask {
|
||||
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
this.bugname = `${this.bugname_prefix}openai_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.SpeechStarted, this._onOpenAISpeechStarted.bind(this, cs, ep));
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.SpeechStopped, this._onOpenAISpeechStopped.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Error,
|
||||
this._onOpenAIErrror.bind(this, cs, ep));
|
||||
|
||||
/* openai delta transcripts are useful only for minBargeinWordCount eval */
|
||||
if (this.minBargeinWordCount > 1) {
|
||||
this.openaiPartials = [];
|
||||
opts.OPENAI_WANT_PARTIALS = 1;
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.PartialTranscript, this._onOpenAIPartialTranscript.bind(this, cs, ep));
|
||||
}
|
||||
this.modelSupportsConversationTracking = opts.OPENAI_MODEL !== 'whisper-1';
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
||||
@@ -584,6 +619,25 @@ class TaskGather extends SttTask {
|
||||
bugname: this.bugname
|
||||
}, 'Gather:_startTranscribing');
|
||||
|
||||
|
||||
/* special feature for openai: we can provide a prompt that includes recent conversation history */
|
||||
let prompt;
|
||||
if (this.vendor === 'openai') {
|
||||
if (this.modelSupportsConversationTracking) {
|
||||
prompt = this.formatOpenAIPrompt(this.cs, {
|
||||
prompt: this.data.recognizer?.openaiOptions?.prompt,
|
||||
hintsTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.hintsTemplate,
|
||||
// eslint-disable-next-line max-len
|
||||
conversationHistoryTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.conversationHistoryTemplate,
|
||||
hints: this.data.recognizer?.hints,
|
||||
});
|
||||
this.logger.debug({prompt}, 'Gather:_startTranscribing - created an openai prompt');
|
||||
}
|
||||
else if (this.data.recognizer?.hints?.length > 0) {
|
||||
prompt = this.data.recognizer?.hints.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Note: we don't need to ask deepgram for interim results, because they
|
||||
* already send us words as they are finalized (is_final=true) even before
|
||||
@@ -595,6 +649,7 @@ class TaskGather extends SttTask {
|
||||
interim: this.interim,
|
||||
bugname: this.bugname,
|
||||
hostport: this.hostport,
|
||||
prompt
|
||||
}).catch((err) => {
|
||||
const {writeAlerts, AlertType} = this.cs.srf.locals;
|
||||
this.logger.error(err, 'TaskGather:_startTranscribing error');
|
||||
@@ -630,7 +685,9 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
|
||||
_startAsrTimer() {
|
||||
if (this.vendor === 'deepgram') return; // no need
|
||||
// Deepgram has a case that UtteranceEnd is not sent to cover the last word end time.
|
||||
// So we need to wait for the asrTimeout to be sure that the last word is sent.
|
||||
// if (this.vendor === 'deepgram') return; // no need
|
||||
assert(this.isContinuousAsr);
|
||||
this._clearAsrTimer();
|
||||
this._asrTimer = setTimeout(() => {
|
||||
@@ -775,7 +832,11 @@ class TaskGather extends SttTask {
|
||||
const bugname = fsEvent.getHeader('media-bugname');
|
||||
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 (bugname && this.bugname !== bugname) {
|
||||
this.logger.debug(
|
||||
`Gather:_onTranscription - ignoring transcript from ${bugname} because our bug is ${this.bugname}`);
|
||||
return;
|
||||
}
|
||||
if (finished === 'true') return;
|
||||
|
||||
if (this.vendor === 'ibm' && evt?.state === 'listening') return;
|
||||
@@ -786,7 +847,8 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
else {
|
||||
const utteranceTime = evt.last_word_end;
|
||||
if (utteranceTime && this._dgTimeOfLastUnprocessedWord && utteranceTime < this._dgTimeOfLastUnprocessedWord) {
|
||||
// eslint-disable-next-line max-len
|
||||
if (utteranceTime && this._dgTimeOfLastUnprocessedWord && utteranceTime < this._dgTimeOfLastUnprocessedWord && utteranceTime != -1) {
|
||||
this.logger.debug('Gather:_onTranscription - got UtteranceEnd with unprocessed words, continue listening');
|
||||
}
|
||||
else {
|
||||
@@ -1078,6 +1140,33 @@ class TaskGather extends SttTask {
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onOpenAIErrror(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onOpenAISpeechStarted(cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onOpenAISpeechStarted');
|
||||
}
|
||||
|
||||
async _onOpenAISpeechStopped(cs, _ep, evt) {
|
||||
this.logger.debug({evt}, 'TaskGather:_onOpenAISpeechStopped');
|
||||
}
|
||||
|
||||
async _onOpenAIPartialTranscript(cs, _ep, evt) {
|
||||
if (!this.playComplete) {
|
||||
const words = evt.delta.split(' ').filter((w) => /[A-Za-z0-0]/.test(w));
|
||||
this.openaiPartials.push(...words);
|
||||
this.logger.debug({words, partials: this.openaiPartials, evt}, 'TaskGather:_onOpenAIPartialTranscript - words');
|
||||
if (this.openaiPartials.length >= this.minBargeinWordCount) {
|
||||
this.logger.debug({partials: this.openaiPartials}, 'killing audio due to speech (openai)');
|
||||
this._killAudio(cs);
|
||||
this.notifyStatus({event: 'speech-bargein-detected', words: this.openaiPartials});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async _onVendorError(cs, _ep, evt) {
|
||||
super._onVendorError(cs, _ep, evt);
|
||||
if (!(await this._startFallback(cs, _ep, evt))) {
|
||||
@@ -1187,6 +1276,7 @@ class TaskGather extends SttTask {
|
||||
}
|
||||
}
|
||||
else if (reason.startsWith('speech')) {
|
||||
this.cs.emit('userSaid', evt.alternatives[0].transcript);
|
||||
if (this.parentTask) this.parentTask.emit('transcription', evt);
|
||||
else {
|
||||
this.emit('transcription', evt);
|
||||
|
||||
@@ -221,7 +221,7 @@ class TaskListen extends Task {
|
||||
}
|
||||
}
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskListen:_onConnect');
|
||||
this.logger.info('TaskListen:_onConnect');
|
||||
}
|
||||
_onConnectFailure(ep, evt) {
|
||||
this.logger.info(evt, 'TaskListen:_onConnectFailure');
|
||||
|
||||
@@ -4,6 +4,8 @@ const TaskLlmOpenAI_S2S = require('./llms/openai_s2s');
|
||||
const TaskLlmVoiceAgent_S2S = require('./llms/voice_agent_s2s');
|
||||
const TaskLlmUltravox_S2S = require('./llms/ultravox_s2s');
|
||||
const TaskLlmElevenlabs_S2S = require('./llms/elevenlabs_s2s');
|
||||
const TaskLlmGoogle_S2S = require('./llms/google_s2s');
|
||||
const LlmMcpService = require('../../utils/llm-mcp');
|
||||
|
||||
class TaskLlm extends Task {
|
||||
constructor(logger, opts) {
|
||||
@@ -18,6 +20,8 @@ class TaskLlm extends Task {
|
||||
|
||||
// delegate to the specific llm model
|
||||
this.llm = this.createSpecificLlm();
|
||||
// MCP
|
||||
this.mcpServers = this.data.mcpServers || [];
|
||||
}
|
||||
|
||||
get name() { return this.llm.name ; }
|
||||
@@ -28,14 +32,32 @@ class TaskLlm extends Task {
|
||||
|
||||
get ep() { return this.cs.ep; }
|
||||
|
||||
get mcpService() {
|
||||
return this.llmMcpService;
|
||||
}
|
||||
|
||||
get isMcpEnabled() {
|
||||
return this.mcpServers.length > 0;
|
||||
}
|
||||
|
||||
async exec(cs, {ep}) {
|
||||
await super.exec(cs, {ep});
|
||||
|
||||
// create the MCP service if we have MCP servers
|
||||
if (this.isMcpEnabled) {
|
||||
this.llmMcpService = new LlmMcpService(this.logger, this.mcpServers);
|
||||
await this.llmMcpService.init();
|
||||
}
|
||||
await this.llm.exec(cs, {ep});
|
||||
}
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
await this.llm.kill(cs);
|
||||
// clean up MCP clients
|
||||
if (this.isMcpEnabled) {
|
||||
await this.mcpService.close();
|
||||
}
|
||||
}
|
||||
|
||||
createSpecificLlm() {
|
||||
@@ -59,6 +81,10 @@ class TaskLlm extends Task {
|
||||
llm = new TaskLlmElevenlabs_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
case 'google':
|
||||
llm = new TaskLlmGoogle_S2S(this.logger, this.data, this);
|
||||
break;
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported vendor ${this.vendor} for LLM`);
|
||||
}
|
||||
@@ -82,8 +108,15 @@ class TaskLlm extends Task {
|
||||
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});
|
||||
const tool_response = await this.cs?.requestor.request('llm:tool-call', this.toolHook, {tool_call_id, ...data});
|
||||
// if the toolHook was a websocket it will return undefined, otherwise it should return an object
|
||||
if (typeof tool_response != 'undefined') {
|
||||
tool_response.type = 'client_tool_result';
|
||||
tool_response.invocation_id = tool_call_id;
|
||||
this.processToolOutput(tool_call_id, tool_response);
|
||||
}
|
||||
}
|
||||
|
||||
async processToolOutput(tool_call_id, data) {
|
||||
|
||||
@@ -164,7 +164,9 @@ class TaskLlmElevenlabs_S2S extends Task {
|
||||
|
||||
try {
|
||||
const {host, path} = await this.getSignedUrl();
|
||||
const args = [ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path];
|
||||
const args = this.conversation_initiation_client_data ?
|
||||
[ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path] :
|
||||
[ep.uuid, 'session.create', this.input_sample_rate, this.output_sample_rate, host, path, 'no_initial_config'];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmElevenlabs_S2S:_startListening');
|
||||
@@ -189,7 +191,7 @@ class TaskLlmElevenlabs_S2S extends Task {
|
||||
if (this.conversation_initiation_client_data) {
|
||||
if (!await this._sendClientEvent(ep, {
|
||||
type: 'conversation_initiation_client_data',
|
||||
conversation_initiation_client_data: this.conversation_initiation_client_data
|
||||
...this.conversation_initiation_client_data
|
||||
})) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
@@ -242,13 +244,36 @@ class TaskLlmElevenlabs_S2S extends Task {
|
||||
/* tool calls */
|
||||
else if (type === 'client_tool_call') {
|
||||
this.logger.debug({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call');
|
||||
if (!this.toolHook) {
|
||||
const {tool_name: name, tool_call_id: call_id, parameters: args} = evt.client_tool_call;
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools.some((tool) => tool.name === name)) {
|
||||
this.logger.debug({name, args}, 'TaskLlmElevenlabs_S2S:_onServerEvent - calling mcp tool');
|
||||
try {
|
||||
const res = await this.parent.mcpService.callMcpTool(name, args);
|
||||
this.logger.debug({res}, 'TaskLlmElevenlabs_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(ep, call_id, {
|
||||
data: {
|
||||
type: 'client_tool_result',
|
||||
tool_call_id: call_id,
|
||||
result: res.content?.length ? res.content[0] : res.content,
|
||||
is_error: false
|
||||
}
|
||||
});
|
||||
return;
|
||||
}
|
||||
catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmElevenlabs_S2S - error calling mcp tool');
|
||||
this.results = {
|
||||
completionReason: 'client error calling mcp function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
} else if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmElevenlabs_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {client_tool_call} = evt;
|
||||
const {tool_name: name, tool_call_id: call_id, parameters: args} = client_tool_call;
|
||||
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
|
||||
313
lib/tasks/llm/llms/google_s2s.js
Normal file
313
lib/tasks/llm/llms/google_s2s.js
Normal file
@@ -0,0 +1,313 @@
|
||||
const Task = require('../../task');
|
||||
const TaskName = 'Llm_Google_s2s';
|
||||
const {LlmEvents_Google} = require('../../../utils/constants');
|
||||
const ClientEvent = 'client.event';
|
||||
const SessionDelete = 'session.delete';
|
||||
|
||||
const google_server_events = [
|
||||
'error',
|
||||
'session.created',
|
||||
'session.updated',
|
||||
];
|
||||
|
||||
const expandWildcards = (events) => {
|
||||
const expandedEvents = [];
|
||||
|
||||
events.forEach((evt) => {
|
||||
if (evt.endsWith('.*')) {
|
||||
const prefix = evt.slice(0, -2); // Remove the wildcard ".*"
|
||||
const matchingEvents = google_server_events.filter((e) => e.startsWith(prefix));
|
||||
expandedEvents.push(...matchingEvents);
|
||||
} else {
|
||||
expandedEvents.push(evt);
|
||||
}
|
||||
});
|
||||
|
||||
return expandedEvents;
|
||||
};
|
||||
|
||||
class TaskLlmGoogle_S2S extends Task {
|
||||
constructor(logger, opts, parentTask) {
|
||||
super(logger, opts, parentTask);
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model || 'gemini-2o';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
|
||||
|
||||
this.apiKey = apiKey;
|
||||
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
|
||||
const {setup} = this.data.llmOptions;
|
||||
|
||||
if (typeof setup !== 'object') {
|
||||
throw new Error('llmOptions with an initial setup is required for Google S2S');
|
||||
}
|
||||
this.setup = {
|
||||
...setup,
|
||||
// make sure output is always audio
|
||||
generationConfig: {
|
||||
...(setup.generationConfig || {}),
|
||||
responseModalities: 'audio'
|
||||
}
|
||||
};
|
||||
|
||||
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 || google_server_events);
|
||||
|
||||
this.addCustomEventListener = parentTask.addCustomEventListener.bind(parentTask);
|
||||
this.removeCustomEventListeners = parentTask.removeCustomEventListeners.bind(parentTask);
|
||||
}
|
||||
|
||||
get name() { return TaskName; }
|
||||
|
||||
async _api(ep, args) {
|
||||
const res = await ep.api('uuid_google_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}, 'TaskLlmGoogle_S2S:kill - error deleting session'));
|
||||
|
||||
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 = google_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
|
||||
}, 'TaskLlmGoogle_S2S:_populateEvents');
|
||||
}
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
try {
|
||||
const args = [ep.uuid, 'session.create', this.apiKey];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
async _sendClientEvent(ep, obj) {
|
||||
let ok = true;
|
||||
this.logger.debug({obj}, 'TaskLlmGoogle_S2S:_sendClientEvent');
|
||||
try {
|
||||
const args = [ep.uuid, ClientEvent, JSON.stringify(obj)];
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
ok = false;
|
||||
this.logger.error({err}, 'TaskLlmGoogle_S2S:_sendClientEvent - Error');
|
||||
}
|
||||
return ok;
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
const setup = this.setup;
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
const convertedTools = mcpTools.map((tool) => {
|
||||
return {
|
||||
functionDeclarations: {
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema,
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
// merge with any existing tools
|
||||
setup.tools = [...convertedTools, ...(this.setup.tools || [])];
|
||||
}
|
||||
if (!await this._sendClientEvent(ep, {
|
||||
setup,
|
||||
})) {
|
||||
this.logger.debug(this.setup, 'TaskLlmGoogle_S2S:_sendInitialMessage - sending session.update');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_registerHandlers(ep) {
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.Connect, this._onConnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.ConnectFailure, this._onConnectFailure.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.Disconnect, this._onDisconnect.bind(this, ep));
|
||||
this.addCustomEventListener(ep, LlmEvents_Google.ServerEvent, this._onServerEvent.bind(this, ep));
|
||||
}
|
||||
|
||||
_unregisterHandlers() {
|
||||
this.removeCustomEventListeners();
|
||||
}
|
||||
|
||||
_onError(ep, evt) {
|
||||
this.logger.info({evt}, 'TaskLlmGoogle_S2S:_onError');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmGoogle_S2S:_onConnect');
|
||||
this._sendInitialMessage(ep);
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmGoogle_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'connection failure'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
_onDisconnect(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmGoogle_S2S:_onConnectFailure');
|
||||
this.results = {completionReason: 'disconnect from remote end'};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
|
||||
async _onServerEvent(ep, evt) {
|
||||
let endConversation = false;
|
||||
this.logger.debug({evt}, 'TaskLlmGoogle_S2S:_onServerEvent');
|
||||
const {toolCall /**toolCallCancellation*/} = evt;
|
||||
|
||||
if (toolCall) {
|
||||
this.logger.debug({toolCall}, 'TaskLlmGoogle_S2S:_onServerEvent - toolCall');
|
||||
if (!this.toolHook) {
|
||||
this.logger.info({evt}, 'TaskLlmGoogle_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {functionCalls} = toolCall;
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
const functionResponses = [];
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
for (const functionCall of functionCalls) {
|
||||
const {name, args, id} = functionCall;
|
||||
const tool = mcpTools.find((tool) => tool.name === name);
|
||||
if (tool) {
|
||||
const response = await this.parent.mcpService.callMcpTool(name, args);
|
||||
functionResponses.push({
|
||||
response: {
|
||||
output: response,
|
||||
},
|
||||
id
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (functionResponses && functionResponses.length > 0) {
|
||||
this.logger.debug({functionResponses}, 'TaskLlmGoogle_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(ep, 'tool_call_id', {
|
||||
toolResponse: {
|
||||
functionResponses
|
||||
}
|
||||
});
|
||||
} else {
|
||||
try {
|
||||
await this.parent.sendToolHook('function_call_id', {type: 'toolCall', functionCalls});
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmGoogle_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
this._sendLlmEvent('llm_event', evt);
|
||||
|
||||
if (endConversation) {
|
||||
this.logger.info({results: this.results},
|
||||
'TaskLlmGoogle_S2S:_onServerEvent - ending conversation due to error');
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
|
||||
_sendLlmEvent(type, evt) {
|
||||
/* 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}, 'TaskLlmGoogle_S2S:_onServerEvent - error sending event hook'));
|
||||
}
|
||||
}
|
||||
|
||||
async processLlmUpdate(ep, data, _callSid) {
|
||||
try {
|
||||
this.logger.debug({data, _callSid}, 'TaskLlmGoogle_S2S:processLlmUpdate');
|
||||
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmGoogle_S2S:processLlmUpdate - Error processing LLM update');
|
||||
}
|
||||
}
|
||||
|
||||
async processToolOutput(ep, tool_call_id, data) {
|
||||
try {
|
||||
this.logger.debug({tool_call_id, data}, 'TaskLlmGoogle_S2S:processToolOutput');
|
||||
const {toolResponse} = data;
|
||||
|
||||
if (!toolResponse) {
|
||||
this.logger.info({data},
|
||||
'TaskLlmGoogle_S2S:processToolOutput - invalid tool output, must be functionResponses');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, data}, 'TaskLlmGoogle_S2S:processToolOutput - Error processing tool output');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = TaskLlmGoogle_S2S;
|
||||
@@ -235,6 +235,23 @@ class TaskLlmOpenAI_S2S extends Task {
|
||||
|
||||
/* send immediate session.update if present */
|
||||
else if (this.session_update) {
|
||||
if (this.parent.isMcpEnabled) {
|
||||
this.logger.debug('TaskLlmOpenAI_S2S:_sendInitialMessage - mcp enabled');
|
||||
const tools = await this.parent.mcpService.getAvailableMcpTools();
|
||||
if (tools && tools.length > 0 && this.session_update) {
|
||||
const convertedTools = tools.map((tool) => ({
|
||||
name: tool.name,
|
||||
type: 'function',
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
}));
|
||||
|
||||
this.session_update.tools = [
|
||||
...convertedTools,
|
||||
...(this.session_update.tools || [])
|
||||
];
|
||||
}
|
||||
}
|
||||
obj = {type: 'session.update', session: this.session_update};
|
||||
this.logger.debug({obj}, 'TaskLlmOpenAI_S2S:_sendInitialMessage - sending session.update');
|
||||
if (!await this._sendClientEvent(ep, obj)) {
|
||||
@@ -299,13 +316,37 @@ class TaskLlmOpenAI_S2S extends Task {
|
||||
/* 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) {
|
||||
const {name, call_id} = evt.item;
|
||||
const args = JSON.parse(evt.item.arguments);
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools.some((tool) => tool.name === name)) {
|
||||
this.logger.debug({call_id, name, args}, 'TaskLlmOpenAI_S2S:_onServerEvent - calling mcp tool');
|
||||
try {
|
||||
const res = await this.parent.mcpService.callMcpTool(name, args);
|
||||
this.logger.debug({res}, 'TaskLlmOpenAI_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(ep, call_id, {
|
||||
type: 'conversation.item.create',
|
||||
item: {
|
||||
type: 'function_call_output',
|
||||
call_id,
|
||||
output: res.content[0]?.text || 'There is no output from the function call',
|
||||
}
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmOpenAI_S2S - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling mcp function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
}
|
||||
else 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) {
|
||||
|
||||
@@ -39,6 +39,10 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
|
||||
this.results = {
|
||||
completionReason: 'normal conversation end'
|
||||
};
|
||||
|
||||
/**
|
||||
* only one of these will have items,
|
||||
* if includeEvents, then these are the events to include
|
||||
@@ -63,7 +67,50 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Converts a JSON Schema to the dynamic parameters format used in the Ultravox API
|
||||
* @param {Object} jsonSchema - A JSON Schema object defining parameters
|
||||
* @param {string} locationDefault - Default location value for parameters (default: 'PARAMETER_LOCATION_BODY')
|
||||
* @returns {Array} Array of dynamic parameters objects
|
||||
*/
|
||||
transformSchemaToParameters(jsonSchema, locationDefault = 'PARAMETER_LOCATION_BODY') {
|
||||
if (jsonSchema.properties) {
|
||||
const required = jsonSchema.required || [];
|
||||
|
||||
return Object.entries(jsonSchema.properties).map(([name]) => {
|
||||
return {
|
||||
name,
|
||||
location: locationDefault,
|
||||
required: required.includes(name)
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
async createCall() {
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
const convertedTools = mcpTools.map((tool) => {
|
||||
return {
|
||||
temporaryTool: {
|
||||
modelToolName: tool.name,
|
||||
description: tool.description,
|
||||
dynamicParameters: this.transformSchemaToParameters(tool.inputSchema),
|
||||
// use client tool that ultravox call tool via freeswitch module.
|
||||
client: {}
|
||||
}
|
||||
};
|
||||
}
|
||||
);
|
||||
// merge with any existing tools
|
||||
this.data.llmOptions.selectedTools = [
|
||||
...convertedTools,
|
||||
...(this.data.llmOptions.selectedTools || [])
|
||||
];
|
||||
}
|
||||
|
||||
const payload = {
|
||||
...this.data.llmOptions,
|
||||
model: this.model,
|
||||
@@ -85,10 +132,10 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
});
|
||||
const data = await body.json();
|
||||
if (statusCode !== 201 || !data?.joinUrl) {
|
||||
this.logger.error({statusCode, data}, 'Ultravox Error registering call');
|
||||
throw new Error(`Ultravox Error registering call: ${data.message}`);
|
||||
this.logger.info({statusCode, data}, 'Ultravox Error registering call');
|
||||
throw new Error(`Ultravox Error registering call:${statusCode} - ${data.detail}`);
|
||||
}
|
||||
this.logger.info({joinUrl: data.joinUrl}, 'Ultravox Call registered');
|
||||
this.logger.debug({joinUrl: data.joinUrl}, 'Ultravox Call registered');
|
||||
return data;
|
||||
}
|
||||
|
||||
@@ -106,12 +153,11 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
async _startListening(cs, ep) {
|
||||
this._registerHandlers(ep);
|
||||
|
||||
const data = await this.createCall();
|
||||
const {joinUrl} = data;
|
||||
// split the joinUrl into host and path
|
||||
const {host, pathname, search} = new URL(joinUrl);
|
||||
|
||||
try {
|
||||
const data = await this.createCall();
|
||||
const {joinUrl} = data;
|
||||
// split the joinUrl into host and path
|
||||
const {host, pathname, search} = new URL(joinUrl);
|
||||
const args = [ep.uuid, 'session.create', host, pathname + search];
|
||||
await this._api(ep, args);
|
||||
// Notify the application that the session has been created with detail information
|
||||
@@ -120,7 +166,8 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
...data
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmUltraVox_S2S:_startListening');
|
||||
this.logger.info({err}, 'TaskLlmUltraVox_S2S:_startListening - Error sending createCall');
|
||||
this.results = {completionReason: `connection failure - ${err}`};
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
@@ -148,7 +195,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
}
|
||||
|
||||
_onConnect(ep) {
|
||||
this.logger.debug('TaskLlmUltravox_S2S:_onConnect');
|
||||
this.logger.info('TaskLlmUltravox_S2S:_onConnect');
|
||||
}
|
||||
_onConnectFailure(_ep, evt) {
|
||||
this.logger.info(evt, 'TaskLlmUltravox_S2S:_onConnectFailure');
|
||||
@@ -164,7 +211,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
async _onServerEvent(_ep, evt) {
|
||||
let endConversation = false;
|
||||
const type = evt.type;
|
||||
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent');
|
||||
|
||||
/* server errors of some sort */
|
||||
if (type === 'error') {
|
||||
@@ -178,12 +225,35 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
/* tool calls */
|
||||
else if (type === 'client_tool_invocation') {
|
||||
this.logger.debug({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call');
|
||||
if (!this.toolHook) {
|
||||
this.logger.warn({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - no toolHook defined!');
|
||||
const {toolName: name, invocationId: call_id, parameters: args} = evt;
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools.some((tool) => tool.name === name)) {
|
||||
this.logger.debug({
|
||||
name,
|
||||
input: args
|
||||
}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call - mcp tool');
|
||||
try {
|
||||
const res = await this.parent.mcpService.callMcpTool(name, args);
|
||||
this.logger.debug({res}, 'TaskLlmUltravox_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(_ep, call_id, {
|
||||
type: 'client_tool_result',
|
||||
invocation_id: call_id,
|
||||
result: res.content
|
||||
});
|
||||
return;
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmUltravox_S2S - error calling mcp tool');
|
||||
this.results = {
|
||||
completionReason: 'client error calling mcp function',
|
||||
error: err
|
||||
};
|
||||
endConversation = true;
|
||||
}
|
||||
} else if (!this.toolHook) {
|
||||
this.logger.info({evt}, 'TaskLlmUltravox_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {toolName: name, invocationId: call_id, parameters: args} = evt;
|
||||
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
} catch (err) {
|
||||
@@ -221,13 +291,14 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
if (!data.type || ![
|
||||
'input_text_message'
|
||||
].includes(data.type)) {
|
||||
this.logger.info({data}, 'TaskLlmUltravox_S2S:processLlmUpdate - invalid mid-call request');
|
||||
this.logger.info({data},
|
||||
'TaskLlmUltravox_S2S:processLlmUpdate - invalid mid-call request, only input_text_message supported');
|
||||
}
|
||||
else {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmUltravox_S2S:processLlmUpdate');
|
||||
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processLlmUpdate - Error processing LLM update');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -243,7 +314,7 @@ class TaskLlmUltravox_S2S extends Task {
|
||||
await this._api(ep, [ep.uuid, ClientEvent, JSON.stringify(data)]);
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err}, 'TaskLlmUltravox_S2S:processToolOutput');
|
||||
this.logger.info({err, data}, 'TaskLlmUltravox_S2S:processToolOutput - Error processing tool output');
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -29,7 +29,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
this.parent = parentTask;
|
||||
|
||||
this.vendor = this.parent.vendor;
|
||||
this.model = this.parent.model;
|
||||
this.model = this.parent.model || 'voice-agent';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
|
||||
@@ -41,25 +41,25 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
const {settingsConfiguration} = this.data.llmOptions;
|
||||
const {Settings} = this.data.llmOptions;
|
||||
|
||||
if (typeof settingsConfiguration !== 'object') {
|
||||
throw new Error('llmOptions with an initial settingsConfiguration is required for VoiceAgent S2S');
|
||||
if (typeof Settings !== 'object') {
|
||||
throw new Error('llmOptions with an initial Settings is required for VoiceAgent S2S');
|
||||
}
|
||||
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {audio, ...rest} = settingsConfiguration;
|
||||
const cfg = this.settingsConfiguration = rest;
|
||||
const {audio, ...rest} = Settings;
|
||||
const cfg = this.Settings = rest;
|
||||
|
||||
if (!cfg.agent) throw new Error('llmOptions.settingsConfiguration.agent is required for VoiceAgent S2S');
|
||||
if (!cfg.agent) throw new Error('llmOptions.Settings.agent is required for VoiceAgent S2S');
|
||||
if (!cfg.agent.think) {
|
||||
throw new Error('llmOptions.settingsConfiguration.agent.think is required for VoiceAgent S2S');
|
||||
throw new Error('llmOptions.Settings.agent.think is required for VoiceAgent S2S');
|
||||
}
|
||||
if (!cfg.agent.think.model) {
|
||||
throw new Error('llmOptions.settingsConfiguration.agent.think.model is required for VoiceAgent S2S');
|
||||
if (!cfg.agent.think.provider?.model) {
|
||||
throw new Error('llmOptions.Settings.agent.think.provider.model is required for VoiceAgent S2S');
|
||||
}
|
||||
if (!cfg.agent.think.provider?.type) {
|
||||
throw new Error('llmOptions.settingsConfiguration.agent.think.provider.type is required for VoiceAgent S2S');
|
||||
throw new Error('llmOptions.Settings.agent.think.provider.type is required for VoiceAgent S2S');
|
||||
}
|
||||
|
||||
this.results = {
|
||||
@@ -92,7 +92,7 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
const {path} = this.connectionOptions || {};
|
||||
if (path) return path;
|
||||
|
||||
return '/agent';
|
||||
return '/v1/agent/converse';
|
||||
}
|
||||
|
||||
async _api(ep, args) {
|
||||
@@ -193,7 +193,20 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
}
|
||||
|
||||
async _sendInitialMessage(ep) {
|
||||
if (!await this._sendClientEvent(ep, this.settingsConfiguration)) {
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (mcpTools && mcpTools.length > 0 && this.Settings.agent?.think) {
|
||||
const convertedTools = mcpTools.map((tool) => ({
|
||||
name: tool.name,
|
||||
description: tool.description,
|
||||
parameters: tool.inputSchema
|
||||
}));
|
||||
|
||||
this.Settings.agent.think.functions = [
|
||||
...convertedTools,
|
||||
...(this.Settings.agent.think?.functions || [])
|
||||
];
|
||||
}
|
||||
if (!await this._sendClientEvent(ep, this.Settings)) {
|
||||
this.notifyTaskDone();
|
||||
}
|
||||
}
|
||||
@@ -254,17 +267,43 @@ class TaskLlmVoiceAgent_S2S extends Task {
|
||||
/* tool calls */
|
||||
else if (type === 'FunctionCallRequest') {
|
||||
this.logger.debug({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call');
|
||||
if (!this.toolHook) {
|
||||
|
||||
const mcpTools = this.parent.isMcpEnabled ? await this.parent.mcpService.getAvailableMcpTools() : [];
|
||||
if (!this.toolHook && mcpTools.length === 0) {
|
||||
this.logger.warn({evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - no toolHook defined!');
|
||||
}
|
||||
else {
|
||||
const {function_name:name, function_call_id:call_id} = evt;
|
||||
const args = evt.input;
|
||||
} else {
|
||||
const {functions} = evt;
|
||||
const handledFunctions = [];
|
||||
|
||||
try {
|
||||
await this.parent.sendToolHook(call_id, {name, args});
|
||||
if (mcpTools && mcpTools.length > 0) {
|
||||
for (const func of functions) {
|
||||
const {name, arguments: args, id} = func;
|
||||
const tool = mcpTools.find((tool) => tool.name === name);
|
||||
if (tool) {
|
||||
handledFunctions.push(name);
|
||||
const response = await this.parent.mcpService.callMcpTool(name, JSON.parse(args));
|
||||
this.logger.debug({response}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - function_call - mcp result');
|
||||
this.processToolOutput(_ep, id, {
|
||||
data: {
|
||||
type: 'FunctionCallResponse',
|
||||
id,
|
||||
name,
|
||||
content: response.length > 0 ? response[0].text : 'There is no output from the function call'
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const func of functions) {
|
||||
const {name, arguments: args, id} = func;
|
||||
if (!handledFunctions.includes(name)) {
|
||||
await this.parent.sendToolHook(id, {name, args: JSON.parse(args)});
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
this.logger.info({err, evt}, 'TaskLlmVoiceAgent - error calling function');
|
||||
this.logger.info({err, evt}, 'TaskLlmVoiceAgent_S2S:_onServerEvent - error calling function');
|
||||
this.results = {
|
||||
completionReason: 'client error calling function',
|
||||
error: err
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName, TaskPreconditions} = require('../utils/constants');
|
||||
const { PlayFileNotFoundError } = require('../utils/error');
|
||||
|
||||
class TaskPlay extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
@@ -27,6 +26,7 @@ class TaskPlay extends Task {
|
||||
let playbackSeconds = 0;
|
||||
let playbackMilliseconds = 0;
|
||||
let completed = !(this.timeoutSecs > 0 || this.loop);
|
||||
cs.playingAudio = true;
|
||||
if (this.timeoutSecs > 0) {
|
||||
timeout = setTimeout(async() => {
|
||||
completed = true;
|
||||
@@ -40,6 +40,22 @@ class TaskPlay extends Task {
|
||||
try {
|
||||
this.notifyStatus({event: 'start-playback'});
|
||||
while (!this.killed && (this.loop === 'forever' || this.loop--) && this.ep.connected) {
|
||||
/* Listen for playback-start event and set up a one-time listener for uuid_break
|
||||
* that will kill the audio playback if the taskIds match. This ensures that
|
||||
* we only kill the currently playing audio and not audio from other tasks.
|
||||
* As we are using stickyEventEmitter, even if the event is emitted before the listener is registered,
|
||||
* the listener will receive the most recent event.
|
||||
*/
|
||||
ep.once('playback-start', (evt) => {
|
||||
this.logger.debug({evt}, 'Play got playback-start');
|
||||
this.cs.stickyEventEmitter.once('uuid_break', (t) => {
|
||||
if (t?.taskId === this.taskId) {
|
||||
this.logger.debug(`Play got kill-playback, executing uuid_break, taskId: ${t?.taskId}`);
|
||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
}
|
||||
});
|
||||
});
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName, confUuid} = cs;
|
||||
if (Array.isArray(this.url)) {
|
||||
@@ -87,15 +103,15 @@ class TaskPlay extends Task {
|
||||
|
||||
async kill(cs) {
|
||||
super.kill(cs);
|
||||
if (this.ep.connected && !this.playComplete) {
|
||||
if (this.ep?.connected && !this.playComplete) {
|
||||
this.logger.debug('TaskPlay:kill - killing audio');
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
//this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
|
||||
cs.stickyEventEmitter.emit('uuid_break', this);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -107,7 +107,7 @@ class TaskSay extends TtsTask {
|
||||
throw new SpeechCredentialError(
|
||||
`No text-to-speech service credentials for ${vendor} with labels: ${label} have been configured`);
|
||||
}
|
||||
|
||||
this.ep = ep;
|
||||
try {
|
||||
|
||||
await this.setTtsStreamingChannelVars(vendor, language, voice, credentials, ep);
|
||||
@@ -223,7 +223,19 @@ class TaskSay extends TtsTask {
|
||||
});
|
||||
ep.once('playback-stop', (evt) => {
|
||||
this.logger.debug({evt}, 'Say got playback-stop');
|
||||
if (evt.variable_tts_error) {
|
||||
this.notifyStatus({event: 'stop-playback'});
|
||||
this.notifiedPlayBackStop = true;
|
||||
const tts_error = evt.variable_tts_error;
|
||||
let response_code = 200;
|
||||
// Check if any property ends with _response_code
|
||||
for (const [key, value] of Object.entries(evt)) {
|
||||
if (key.endsWith('_response_code')) {
|
||||
response_code = parseInt(value, 10) || 200;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (tts_error) {
|
||||
writeAlerts({
|
||||
account_sid,
|
||||
alert_type: AlertType.TTS_FAILURE,
|
||||
@@ -232,7 +244,7 @@ class TaskSay extends TtsTask {
|
||||
target_sid
|
||||
}).catch((err) => this.logger.info({err}, 'Error generating alert for no tts'));
|
||||
}
|
||||
if (evt.variable_tts_cache_filename && !this.killed) {
|
||||
if (!tts_error && response_code < 300 && evt.variable_tts_cache_filename && !this.killed) {
|
||||
const text = parseTextFromSayString(this.text[segment]);
|
||||
addFileToCache(evt.variable_tts_cache_filename, {
|
||||
account_sid,
|
||||
@@ -241,12 +253,14 @@ class TaskSay extends TtsTask {
|
||||
voice,
|
||||
engine,
|
||||
model: this.model || this.model_id,
|
||||
text
|
||||
text,
|
||||
instructions: this.instructions
|
||||
}).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();
|
||||
(tts_error || response_code >= 300) ? this._playReject(new Error(evt.variable_tts_error)) :
|
||||
this._playResolve();
|
||||
}
|
||||
});
|
||||
// wait for playback-stop event received to confirm if the playback is successful
|
||||
@@ -292,8 +306,13 @@ class TaskSay extends TtsTask {
|
||||
if (cs.isInConference) {
|
||||
const {memberId, confName} = cs;
|
||||
this.killPlayToConfMember(this.ep, memberId, confName);
|
||||
}
|
||||
else {
|
||||
} else if (this.isStreamingTts) {
|
||||
this.logger.debug('TaskSay:kill - clearing TTS stream for streaming audio');
|
||||
cs.clearTtsStream();
|
||||
} else {
|
||||
if (!this.notifiedPlayBackStop) {
|
||||
this.notifyStatus({event: 'stop-playback'});
|
||||
}
|
||||
this.notifyStatus({event: 'kill-playback'});
|
||||
this.ep.api('uuid_break', this.ep.uuid);
|
||||
}
|
||||
@@ -314,6 +333,7 @@ class TaskSay extends TtsTask {
|
||||
if (key.startsWith('variable_tts_')) {
|
||||
let newKey = key.substring('variable_tts_'.length)
|
||||
.replace('whisper_', 'whisper.')
|
||||
.replace('nvidia_', 'nvidia.')
|
||||
.replace('deepgram_', 'deepgram.')
|
||||
.replace('playht_', 'playht.')
|
||||
.replace('cartesia_', 'cartesia.')
|
||||
|
||||
@@ -18,6 +18,11 @@ class TaskSipDecline extends Task {
|
||||
super.exec(cs);
|
||||
res.send(this.data.status, this.data.reason, {
|
||||
headers: this.headers
|
||||
}, (err) => {
|
||||
if (!err) {
|
||||
// Call was successfully declined
|
||||
cs._callReleased();
|
||||
}
|
||||
});
|
||||
cs.emit('callStatusChange', {
|
||||
callStatus: CallStatus.Failed,
|
||||
|
||||
@@ -5,6 +5,30 @@ const { TaskPreconditions, CobaltTranscriptionEvents } = require('../utils/const
|
||||
const { SpeechCredentialError } = require('../utils/error');
|
||||
const {JAMBONES_AWS_TRANSCRIBE_USE_GRPC} = require('../config');
|
||||
|
||||
/**
|
||||
* "Please insert turns here: {{turns:4}}"
|
||||
// -> { processed: 'Please insert turns here: {{turns}}', turns: 4 }
|
||||
|
||||
processTurnString("Please insert turns here: {{turns}}"));
|
||||
// -> { processed: 'Please insert turns here: {{turns}}', turns: null }
|
||||
*/
|
||||
const processTurnString = (input) => {
|
||||
const regex = /\{\{turns(?::(\d+))?\}\}/;
|
||||
const match = input.match(regex);
|
||||
|
||||
if (!match) {
|
||||
return {
|
||||
processed: input,
|
||||
turns: null
|
||||
};
|
||||
}
|
||||
|
||||
const turns = match[1] ? parseInt(match[1], 10) : null;
|
||||
const processed = input.replace(regex, '{{turns}}');
|
||||
|
||||
return { processed, turns };
|
||||
};
|
||||
|
||||
class SttTask extends Task {
|
||||
|
||||
constructor(logger, data, parentTask) {
|
||||
@@ -290,6 +314,57 @@ class SttTask extends Task {
|
||||
});
|
||||
}
|
||||
|
||||
formatOpenAIPrompt(cs, {prompt, hintsTemplate, conversationHistoryTemplate, hints}) {
|
||||
let conversationHistoryPrompt, hintsPrompt;
|
||||
|
||||
/* generate conversation history from template */
|
||||
if (conversationHistoryTemplate) {
|
||||
const {processed, turns} = processTurnString(conversationHistoryTemplate);
|
||||
this.logger.debug({processed, turns}, 'SttTask: processed conversation history template');
|
||||
conversationHistoryPrompt = cs.getFormattedConversation(turns || 4);
|
||||
//this.logger.debug({conversationHistoryPrompt}, 'SttTask: conversation history');
|
||||
if (conversationHistoryPrompt) {
|
||||
conversationHistoryPrompt = processed.replace('{{turns}}', `\n${conversationHistoryPrompt}\nuser: `);
|
||||
}
|
||||
}
|
||||
|
||||
/* generate hints from template */
|
||||
if (hintsTemplate && Array.isArray(hints) && hints.length > 0) {
|
||||
hintsPrompt = hintsTemplate.replace('{{hints}}', hints);
|
||||
}
|
||||
|
||||
/* combine into final prompt */
|
||||
let finalPrompt = prompt || '';
|
||||
if (hintsPrompt) {
|
||||
finalPrompt = `${finalPrompt}\n${hintsPrompt}`;
|
||||
}
|
||||
if (conversationHistoryPrompt) {
|
||||
finalPrompt = `${finalPrompt}\n${conversationHistoryPrompt}`;
|
||||
}
|
||||
|
||||
this.logger.debug({
|
||||
finalPrompt,
|
||||
hints,
|
||||
hintsPrompt,
|
||||
conversationHistoryTemplate,
|
||||
conversationHistoryPrompt
|
||||
}, 'SttTask: formatted OpenAI prompt');
|
||||
return finalPrompt?.trimStart();
|
||||
}
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
doesVendorContinueListeningAfterFinalTranscript(vendor) {
|
||||
return (vendor.startsWith('custom:') || [
|
||||
'soniox',
|
||||
'aws',
|
||||
'microsoft',
|
||||
'deepgram',
|
||||
'google',
|
||||
'speechmatics',
|
||||
'openai',
|
||||
].includes(vendor));
|
||||
}
|
||||
|
||||
_onCompileContext(ep, key, evt) {
|
||||
const {addKey} = this.cs.srf.locals.dbHelpers;
|
||||
this.logger.debug({evt}, `received cobalt compile context event, will cache under ${key}`);
|
||||
|
||||
@@ -19,6 +19,7 @@ class Task extends Emitter {
|
||||
this.data = data;
|
||||
this.actionHook = this.data.actionHook;
|
||||
this.id = data.id;
|
||||
this.taskId = uuidv4();
|
||||
|
||||
this._killInProgress = false;
|
||||
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
|
||||
|
||||
@@ -14,6 +14,7 @@ const {
|
||||
TranscribeStatus,
|
||||
AssemblyAiTranscriptionEvents,
|
||||
VoxistTranscriptionEvents,
|
||||
OpenAITranscriptionEvents,
|
||||
VerbioTranscriptionEvents,
|
||||
SpeechmaticsTranscriptionEvents
|
||||
} = require('../utils/constants.json');
|
||||
@@ -30,7 +31,6 @@ class TaskTranscribe extends SttTask {
|
||||
this.transcriptionHook = this.data.transcriptionHook;
|
||||
this.translationHook = this.data.translationHook;
|
||||
this.earlyMedia = this.data.earlyMedia === true || (parentTask && parentTask.earlyMedia);
|
||||
|
||||
if (this.data.recognizer) {
|
||||
this.interim = !!this.data.recognizer.interim;
|
||||
this.separateRecognitionPerChannel = this.data.recognizer.separateRecognitionPerChannel;
|
||||
@@ -105,7 +105,7 @@ class TaskTranscribe extends SttTask {
|
||||
|
||||
if (cs.hasGlobalSttHints) {
|
||||
const {hints, hintsBoost} = cs.globalSttHints;
|
||||
this.data.recognizer.hints = this.data.recognizer.hints.concat(hints);
|
||||
this.data.recognizer.hints = this.data.recognizer?.hints?.concat(hints);
|
||||
if (!this.data.recognizer.hintsBoost && hintsBoost) this.data.recognizer.hintsBoost = hintsBoost;
|
||||
this.logger.debug({hints: this.data.recognizer.hints, hintsBoost: this.data.recognizer.hintsBoost},
|
||||
'Transcribe:exec - applying global sttHints');
|
||||
@@ -330,6 +330,20 @@ class TaskTranscribe extends SttTask {
|
||||
this._onSpeechmaticsError.bind(this, cs, ep));
|
||||
break;
|
||||
|
||||
case 'openai':
|
||||
this.bugname = `${this.bugname_prefix}openai_transcribe`;
|
||||
this.addCustomEventListener(
|
||||
ep, OpenAITranscriptionEvents.Transcription, this._onTranscription.bind(this, cs, ep, channel));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Connect,
|
||||
this._onVendorConnect.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.ConnectFailure,
|
||||
this._onVendorConnectFailure.bind(this, cs, ep));
|
||||
this.addCustomEventListener(ep, OpenAITranscriptionEvents.Error,
|
||||
this._onOpenAIErrror.bind(this, cs, ep));
|
||||
|
||||
this.modelSupportsConversationTracking = opts.OPENAI_MODEL !== 'whisper-1';
|
||||
break;
|
||||
|
||||
default:
|
||||
if (this.vendor.startsWith('custom:')) {
|
||||
this.bugname = `${this.bugname_prefix}${this.vendor}_transcribe`;
|
||||
@@ -365,6 +379,25 @@ class TaskTranscribe extends SttTask {
|
||||
async _transcribe(ep) {
|
||||
this.logger.debug(
|
||||
`TaskTranscribe:_transcribe - starting transcription vendor ${this.vendor} bugname ${this.bugname}`);
|
||||
|
||||
/* special feature for openai: we can provide a prompt that includes recent conversation history */
|
||||
let prompt;
|
||||
if (this.vendor === 'openai') {
|
||||
if (this.modelSupportsConversationTracking) {
|
||||
prompt = this.formatOpenAIPrompt(this.cs, {
|
||||
prompt: this.data.recognizer?.openaiOptions?.prompt,
|
||||
hintsTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.hintsTemplate,
|
||||
// eslint-disable-next-line max-len
|
||||
conversationHistoryTemplate: this.data.recognizer?.openaiOptions?.promptTemplates?.conversationHistoryTemplate,
|
||||
hints: this.data.recognizer?.hints,
|
||||
});
|
||||
this.logger.debug({prompt}, 'Gather:_startTranscribing - created an openai prompt');
|
||||
}
|
||||
else if (this.data.recognizer?.hints?.length > 0) {
|
||||
prompt = this.data.recognizer?.hints.join(', ');
|
||||
}
|
||||
}
|
||||
|
||||
await ep.startTranscription({
|
||||
vendor: this.vendor,
|
||||
interim: this.interim ? true : false,
|
||||
@@ -456,8 +489,9 @@ class TaskTranscribe extends SttTask {
|
||||
this._startAsrTimer(channel);
|
||||
|
||||
/* some STT engines will keep listening after a final response, so no need to restart */
|
||||
if (!['soniox', 'aws', 'microsoft', 'deepgram', 'google', 'speechmatics']
|
||||
.includes(this.vendor)) this._startTranscribing(cs, ep, channel);
|
||||
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
}
|
||||
else {
|
||||
if (this.vendor === 'soniox') {
|
||||
@@ -480,9 +514,7 @@ class TaskTranscribe extends SttTask {
|
||||
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', 'speechmatics'].includes(this.vendor) &&
|
||||
!this.vendor.startsWith('custom:')) {
|
||||
if (!this.doesVendorContinueListeningAfterFinalTranscript(this.vendor)) {
|
||||
this.logger.debug('TaskTranscribe:_onTranscription - restarting transcribe');
|
||||
this._startTranscribing(cs, ep, channel);
|
||||
}
|
||||
@@ -621,12 +653,21 @@ class TaskTranscribe extends SttTask {
|
||||
}
|
||||
|
||||
_onMaxDurationExceeded(cs, ep, channel) {
|
||||
this.logger.debug(`TaskTranscribe:_onMaxDurationExceeded on channel ${channel}`);
|
||||
this.restartDueToError(ep, channel, 'Max duration exceeded');
|
||||
}
|
||||
|
||||
_onMaxBufferExceeded(cs, ep, channel) {
|
||||
this.restartDueToError(ep, channel, 'Max buffer exceeded');
|
||||
}
|
||||
|
||||
restartDueToError(ep, channel, reason) {
|
||||
this.logger.debug(`TaskTranscribe:${reason} on channel ${channel}`);
|
||||
if (this.paused) return;
|
||||
|
||||
if (this.childSpan[channel - 1] && this.childSpan[channel - 1].span) {
|
||||
this.childSpan[channel - 1].span.setAttributes({
|
||||
channel,
|
||||
'stt.resolve': 'max duration exceeded',
|
||||
'stt.resolve': reason,
|
||||
'stt.label': this.label || 'None',
|
||||
});
|
||||
this.childSpan[channel - 1].span.end();
|
||||
@@ -683,6 +724,14 @@ class TaskTranscribe extends SttTask {
|
||||
return;
|
||||
}
|
||||
this.logger.info({evt}, 'TaskTranscribe:_onJambonzError');
|
||||
if (this.vendor === 'microsoft' &&
|
||||
evt.error?.includes('Due to service inactivity, the client buffer exceeded maximum size. Resetting the buffer')) {
|
||||
let channel = 1;
|
||||
if (this.ep !== _ep) {
|
||||
channel = 2;
|
||||
}
|
||||
return this._onMaxBufferExceeded(cs, _ep, channel);
|
||||
}
|
||||
if (this.paused) return;
|
||||
const {writeAlerts, AlertType} = cs.srf.locals;
|
||||
|
||||
@@ -733,6 +782,12 @@ class TaskTranscribe extends SttTask {
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
async _onOpenAIErrror(cs, _ep, evt) {
|
||||
// eslint-disable-next-line no-unused-vars
|
||||
const {message, ...e} = evt;
|
||||
this._onVendorError(cs, _ep, {error: JSON.stringify(e)});
|
||||
}
|
||||
|
||||
_startAsrTimer(channel) {
|
||||
if (this.vendor === 'deepgram') return; // no need
|
||||
assert(this.isContinuousAsr);
|
||||
|
||||
@@ -21,6 +21,7 @@ class TtsTask extends Task {
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
this.instructions = this.data.instructions;
|
||||
}
|
||||
|
||||
async exec(cs) {
|
||||
@@ -42,6 +43,11 @@ class TtsTask extends Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const fullText = Array.isArray(this.text) ? this.text.join(' ') : this.text;
|
||||
if (fullText.length > 0) {
|
||||
cs.emit('botSaid', fullText);
|
||||
}
|
||||
}
|
||||
|
||||
getTtsVendorData(cs) {
|
||||
@@ -60,7 +66,6 @@ class TtsTask extends Task {
|
||||
|
||||
async setTtsStreamingChannelVars(vendor, language, voice, credentials, ep) {
|
||||
const {api_key, model_id, custom_tts_streaming_url, auth_token} = credentials;
|
||||
const {stability, similarity_boost, use_speaker_boost, style} = this.options;
|
||||
let obj;
|
||||
|
||||
this.logger.debug({credentials},
|
||||
@@ -82,6 +87,7 @@ class TtsTask extends Task {
|
||||
};
|
||||
break;
|
||||
case 'elevenlabs':
|
||||
const {stability, similarity_boost, use_speaker_boost, style, speed} = this.options.voice_settings || {};
|
||||
obj = {
|
||||
ELEVENLABS_API_KEY: api_key,
|
||||
ELEVENLABS_TTS_STREAMING_MODEL_ID: model_id,
|
||||
@@ -91,7 +97,14 @@ class TtsTask extends Task {
|
||||
...(stability && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STABILITY: stability}),
|
||||
...(similarity_boost && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_SIMILARITY_BOOST: similarity_boost}),
|
||||
...(use_speaker_boost && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_USE_SPEAKER_BOOST: use_speaker_boost}),
|
||||
...(style && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STYLE: style})
|
||||
...(style && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_STYLE: style}),
|
||||
// speed has value 0.7 to 1.2, 1.0 is default, make sure we send the value event it's 0
|
||||
...(speed !== null && speed !== undefined && {ELEVENLABS_TTS_STREAMING_VOICE_SETTINGS_SPEED: `${speed}`}),
|
||||
...(this.options.pronunciation_dictionary_locators &&
|
||||
Array.isArray(this.options.pronunciation_dictionary_locators) && {
|
||||
ELEVENLABS_TTS_STREAMING_PRONUNCIATION_DICTIONARY_LOCATORS:
|
||||
JSON.stringify(this.options.pronunciation_dictionary_locators)
|
||||
}),
|
||||
};
|
||||
break;
|
||||
case 'rimelabs':
|
||||
@@ -125,7 +138,7 @@ class TtsTask extends Task {
|
||||
throw new Error(`vendor ${vendor} is not supported for tts streaming yet`);
|
||||
}
|
||||
}
|
||||
this.logger.info({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
|
||||
this.logger.debug({vendor, credentials, obj}, 'setTtsStreamingChannelVars');
|
||||
|
||||
await ep.set(obj);
|
||||
}
|
||||
@@ -152,7 +165,6 @@ class TtsTask extends Task {
|
||||
} else if (vendor === 'deepgram') {
|
||||
this.model = voice;
|
||||
}
|
||||
this.model_id = credentials.model_id;
|
||||
|
||||
/* allow for microsoft custom region voice and api_key to be specified as an override */
|
||||
if (vendor === 'microsoft' && this.options.deploymentId) {
|
||||
@@ -193,8 +205,12 @@ class TtsTask extends Task {
|
||||
};
|
||||
}
|
||||
}
|
||||
} else if (vendor === 'cartesia') {
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
}
|
||||
|
||||
this.model_id = credentials.model_id;
|
||||
|
||||
/**
|
||||
* note on cache_speech_handles. This was found to be risky.
|
||||
* It can cause a crash in the following sequence on a single call:
|
||||
@@ -216,7 +232,7 @@ class TtsTask extends Task {
|
||||
//cs.currentTtsVendor = vendor;
|
||||
|
||||
if (!preCache && !this._disableTracing)
|
||||
this.logger.info({vendor, language, voice, model: this.model}, 'TaskSay:exec');
|
||||
this.logger.debug({vendor, language, voice, model: this.model}, 'TaskSay:exec');
|
||||
try {
|
||||
if (!credentials) {
|
||||
writeAlerts({
|
||||
@@ -247,6 +263,7 @@ class TtsTask extends Task {
|
||||
const {filePath, servedFromCache, rtt} = await synthAudio(stats, {
|
||||
account_sid,
|
||||
text,
|
||||
instructions: this.instructions,
|
||||
vendor,
|
||||
language,
|
||||
voice,
|
||||
|
||||
@@ -51,7 +51,7 @@ class ActionHookDelayProcessor extends Emitter {
|
||||
this.logger.debug({opts}, 'ActionHookDelayProcessor#init');
|
||||
|
||||
this.actions = opts.actions;
|
||||
this.retries = opts.retries || 0;
|
||||
this.retries = Math.max((opts.retries || 1), opts.actions.length);
|
||||
this.noResponseTimeout = opts.noResponseTimeout;
|
||||
this.noResponseGiveUpTimeout = opts.noResponseGiveUpTimeout;
|
||||
this.giveUpActions = opts.giveUpActions;
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
"Tag": "tag",
|
||||
"Transcribe": "transcribe"
|
||||
},
|
||||
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag"],
|
||||
"AllowedSipRecVerbs": ["answer", "config", "gather", "transcribe", "listen", "tag", "hangup", "sip:decline"],
|
||||
"AllowedConfirmSessionVerbs": ["config", "gather", "plays", "say", "tag"],
|
||||
"CallStatus": {
|
||||
"Trying": "trying",
|
||||
@@ -137,6 +137,18 @@
|
||||
"Connect": "speechmatics_transcribe::connect",
|
||||
"Error": "speechmatics_transcribe::error"
|
||||
},
|
||||
"OpenAITranscriptionEvents": {
|
||||
"Transcription": "openai_transcribe::transcription",
|
||||
"Translation": "openai_transcribe::translation",
|
||||
"SpeechStarted": "openai_transcribe::speech_started",
|
||||
"SpeechStopped": "openai_transcribe::speech_stopped",
|
||||
"PartialTranscript": "openai_transcribe::partial_transcript",
|
||||
"Info": "openai_transcribe::info",
|
||||
"RecognitionStarted": "openai_transcribe::recognition_started",
|
||||
"ConnectFailure": "openai_transcribe::connect_failed",
|
||||
"Connect": "openai_transcribe::connect",
|
||||
"Error": "openai_transcribe::error"
|
||||
},
|
||||
"JambonzTranscriptionEvents": {
|
||||
"Transcription": "jambonz_transcribe::transcription",
|
||||
"ConnectFailure": "jambonz_transcribe::connect_failed",
|
||||
@@ -182,6 +194,13 @@
|
||||
"Disconnect": "openai_s2s::disconnect",
|
||||
"ServerEvent": "openai_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_Google": {
|
||||
"Error": "error",
|
||||
"Connect": "google_s2s::connect",
|
||||
"ConnectFailure": "google_s2s::connect_failed",
|
||||
"Disconnect": "google_s2s::disconnect",
|
||||
"ServerEvent": "google_s2s::server_event"
|
||||
},
|
||||
"LlmEvents_Elevenlabs": {
|
||||
"Error": "error",
|
||||
"Connect": "elevenlabs_s2s::connect",
|
||||
|
||||
@@ -142,6 +142,11 @@ const speechMapper = (cred) => {
|
||||
obj.api_key = o.api_key;
|
||||
obj.speechmatics_stt_uri = o.speechmatics_stt_uri;
|
||||
}
|
||||
else if ('openai' === 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:')) {
|
||||
const o = JSON.parse(decrypt(credential));
|
||||
obj.auth_token = o.auth_token;
|
||||
|
||||
@@ -108,7 +108,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
|
||||
assert(HookMsgTypes.includes(type));
|
||||
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
const payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
|
||||
const url = hook.url || hook;
|
||||
const method = hook.method || 'POST';
|
||||
let buf = '';
|
||||
@@ -219,7 +219,7 @@ class HttpRequestor extends BaseRequestor {
|
||||
const rtt = this._roundTrip(startAt);
|
||||
if (buf) this.stats.histogram('app.hook.response_time', rtt, ['hook_type:app']);
|
||||
|
||||
if (buf && Array.isArray(buf)) {
|
||||
if (buf && (Array.isArray(buf) || type == 'llm:tool-call')) {
|
||||
this.logger.info({response: buf}, `HttpRequestor:request ${method} ${url} succeeded in ${rtt}ms`);
|
||||
return buf;
|
||||
}
|
||||
|
||||
@@ -31,18 +31,26 @@ function getLocalIp() {
|
||||
return '127.0.0.1'; // Fallback to localhost if no suitable interface found
|
||||
}
|
||||
|
||||
function initMS(logger, wrapper, ms) {
|
||||
function initMS(logger, wrapper, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
}) {
|
||||
Object.assign(wrapper, {ms, active: true, connects: 1});
|
||||
logger.info(`connected to freeswitch at ${ms.address}`);
|
||||
|
||||
onFreeswitchConnect(wrapper);
|
||||
|
||||
ms.conn
|
||||
.on('esl::end', () => {
|
||||
wrapper.active = false;
|
||||
wrapper.connects = 0;
|
||||
logger.info(`lost connection to freeswitch at ${ms.address}`);
|
||||
onFreeswitchDisconnect(wrapper);
|
||||
ms.removeAllListeners();
|
||||
})
|
||||
.on('esl::ready', () => {
|
||||
if (wrapper.connects > 0) {
|
||||
logger.info(`connected to freeswitch at ${ms.address}`);
|
||||
logger.info(`esl::ready connected to freeswitch at ${ms.address}`);
|
||||
}
|
||||
wrapper.connects = 1;
|
||||
wrapper.active = true;
|
||||
@@ -56,7 +64,10 @@ function initMS(logger, wrapper, ms) {
|
||||
});
|
||||
}
|
||||
|
||||
function installSrfLocals(srf, logger) {
|
||||
function installSrfLocals(srf, logger, {
|
||||
onFreeswitchConnect = () => {},
|
||||
onFreeswitchDisconnect = () => {}
|
||||
}) {
|
||||
logger.debug('installing srf locals');
|
||||
assert(!srf.locals.dbHelpers);
|
||||
const {tracer} = srf.locals.otel;
|
||||
@@ -91,7 +102,10 @@ function installSrfLocals(srf, logger) {
|
||||
mediaservers.push(val);
|
||||
try {
|
||||
const ms = await mrf.connect(fs);
|
||||
initMS(logger, val, ms);
|
||||
initMS(logger, val, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
});
|
||||
}
|
||||
catch (err) {
|
||||
logger.info({err}, `failed connecting to freeswitch at ${fs.address}, will retry shortly: ${err.message}`);
|
||||
@@ -102,9 +116,15 @@ function installSrfLocals(srf, logger) {
|
||||
for (const val of mediaservers) {
|
||||
if (val.connects === 0) {
|
||||
try {
|
||||
// make sure all listeners are removed before reconnecting
|
||||
val.ms?.disconnect();
|
||||
val.ms = null;
|
||||
logger.info({mediaserver: val.opts}, 'Retrying initial connection to media server');
|
||||
const ms = await mrf.connect(val.opts);
|
||||
initMS(logger, val, ms);
|
||||
initMS(logger, val, ms, {
|
||||
onFreeswitchConnect,
|
||||
onFreeswitchDisconnect
|
||||
});
|
||||
} catch (err) {
|
||||
logger.info({err}, `failed connecting to freeswitch at ${val.opts.address}, will retry shortly`);
|
||||
}
|
||||
|
||||
103
lib/utils/llm-mcp.js
Normal file
103
lib/utils/llm-mcp.js
Normal file
@@ -0,0 +1,103 @@
|
||||
const { Client } = require('@modelcontextprotocol/sdk/client/index.js');
|
||||
|
||||
class LlmMcpService {
|
||||
|
||||
constructor(logger, mcpServers) {
|
||||
this.logger = logger;
|
||||
this.mcpServers = mcpServers || [];
|
||||
this.mcpClients = [];
|
||||
}
|
||||
|
||||
// make sure we call init() before using any of the mcp clients
|
||||
// this is to ensure that we have a valid connection to the MCP server
|
||||
// and that we have collected the available tools.
|
||||
async init() {
|
||||
if (this.mcpClients.length > 0) {
|
||||
return;
|
||||
}
|
||||
const { SSEClientTransport } = await import('@modelcontextprotocol/sdk/client/sse.js');
|
||||
for (const server of this.mcpServers) {
|
||||
const { url } = server;
|
||||
if (url) {
|
||||
try {
|
||||
const transport = new SSEClientTransport(new URL(url), {});
|
||||
const client = new Client({ name: 'Jambonz MCP Client', version: '1.0.0' });
|
||||
await client.connect(transport);
|
||||
// collect available tools
|
||||
const { tools } = await client.listTools();
|
||||
this.mcpClients.push({
|
||||
url,
|
||||
client,
|
||||
tools
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.error(`LlmMcpService: Failed to connect to MCP server at ${url}: ${err.message}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getAvailableMcpTools() {
|
||||
// returns a list of available tools from all MCP clients
|
||||
const tools = [];
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const {tools: availableTools} = mcpClient;
|
||||
if (availableTools) {
|
||||
tools.push(...availableTools);
|
||||
}
|
||||
}
|
||||
return tools;
|
||||
}
|
||||
|
||||
async getMcpClientByToolName(name) {
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const { tools } = mcpClient;
|
||||
if (tools && tools.some((tool) => tool.name === name)) {
|
||||
return mcpClient.client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async getMcpClientByToolId(id) {
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const { tools } = mcpClient;
|
||||
if (tools && tools.some((tool) => tool.id === id)) {
|
||||
return mcpClient.client;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async callMcpTool(name, input) {
|
||||
const client = await this.getMcpClientByToolName(name);
|
||||
if (client) {
|
||||
try {
|
||||
const result = await client.callTool({
|
||||
name,
|
||||
arguments: input,
|
||||
});
|
||||
this.logger.debug({result}, 'LlmMcpService - result');
|
||||
return result;
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'LlmMcpService - error calling tool');
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async close() {
|
||||
for (const mcpClient of this.mcpClients) {
|
||||
const { client } = mcpClient;
|
||||
if (client) {
|
||||
await client.close();
|
||||
this.logger.debug({url: mcpClient.url}, 'LlmMcpService - mcp client closed');
|
||||
}
|
||||
}
|
||||
this.mcpClients = [];
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
module.exports = LlmMcpService;
|
||||
|
||||
@@ -15,7 +15,7 @@ const RootSpan = require('./call-tracer');
|
||||
const uuidv4 = require('uuid-random');
|
||||
const HttpRequestor = require('./http-requestor');
|
||||
const WsRequestor = require('./ws-requestor');
|
||||
const {makeOpusFirst} = require('./sdp-utils');
|
||||
const {makeOpusFirst, removeVideoSdp} = require('./sdp-utils');
|
||||
const {
|
||||
JAMBONES_USE_FREESWITCH_TIMER_FD,
|
||||
JAMBONES_MEDIA_TIMEOUT_MS,
|
||||
@@ -154,15 +154,21 @@ class SingleDialer extends Emitter {
|
||||
return;
|
||||
}
|
||||
let lastSdp;
|
||||
const connectStream = async(remoteSdp) => {
|
||||
const connectStream = async(remoteSdp, isVideoCall) => {
|
||||
if (remoteSdp === lastSdp) return;
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !isVideoCall) {
|
||||
remoteSdp = removeVideoSdp(remoteSdp);
|
||||
}
|
||||
lastSdp = remoteSdp;
|
||||
return this.ep.modify(remoteSdp);
|
||||
};
|
||||
|
||||
let localSdp = this.ep.local.sdp;
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !opts.isVideoCall) {
|
||||
localSdp = removeVideoSdp(localSdp);
|
||||
}
|
||||
Object.assign(opts, {
|
||||
proxy: `sip:${this.sbcAddress}`,
|
||||
localSdp: opts.opusFirst ? makeOpusFirst(this.ep.local.sdp) : this.ep.local.sdp
|
||||
localSdp: opts.opusFirst ? makeOpusFirst(localSdp) : localSdp
|
||||
});
|
||||
if (this.target.auth) opts.auth = this.target.auth;
|
||||
inviteSpan = this.startSpan('invite', {
|
||||
@@ -224,13 +230,13 @@ class SingleDialer extends Emitter {
|
||||
status.callStatus = CallStatus.EarlyMedia;
|
||||
this.emit('earlyMedia');
|
||||
}
|
||||
connectStream(prov.body);
|
||||
connectStream(prov.body, opts.isVideoCall);
|
||||
}
|
||||
else status.callStatus = CallStatus.Ringing;
|
||||
this.emit('callStatusChange', status);
|
||||
}
|
||||
});
|
||||
await connectStream(this.dlg.remote.sdp);
|
||||
await connectStream(this.dlg.remote.sdp, opts.isVideoCall);
|
||||
this.dlg.callSid = this.callSid;
|
||||
this.inviteInProgress = null;
|
||||
this.emit('callStatusChange', {
|
||||
@@ -273,7 +279,12 @@ class SingleDialer extends Emitter {
|
||||
this.logger.info('dial is onhold, emit event');
|
||||
this.emit('reinvite', req, res);
|
||||
} else {
|
||||
const newSdp = await this.ep.modify(req.body);
|
||||
let newSdp = await this.ep.modify(req.body);
|
||||
// in case of reINVITE if video call is enabled in FS and the call is not a video call,
|
||||
// remove video media from the SDP
|
||||
if (process.env.JAMBONES_VIDEO_CALLS_ENABLED_IN_FS && !this.opts?.isVideoCall) {
|
||||
newSdp = removeVideoSdp(newSdp);
|
||||
}
|
||||
res.send(200, {body: newSdp});
|
||||
this.logger.info({offer: req.body, answer: newSdp}, 'SingleDialer:exec: handling reINVITE');
|
||||
}
|
||||
@@ -559,7 +570,8 @@ function placeOutdial({
|
||||
}) {
|
||||
const myOpts = deepcopy(opts);
|
||||
const sd = new SingleDialer({
|
||||
logger, sbcAddress, target, myOpts, application, callInfo, accountInfo, rootSpan, startSpan, dialTask, onHoldMusic
|
||||
logger, sbcAddress, target, opts: myOpts, application, callInfo,
|
||||
accountInfo, rootSpan, startSpan, dialTask, onHoldMusic
|
||||
});
|
||||
sd.exec(srf, ms, myOpts);
|
||||
return sd;
|
||||
|
||||
@@ -35,6 +35,12 @@ const makeOpusFirst = (sdp) => {
|
||||
}
|
||||
return sdpTransform.write(parsedSdp);
|
||||
};
|
||||
const removeVideoSdp = (sdp) => {
|
||||
const parsedSdp = sdpTransform.parse(sdp);
|
||||
// Filter out video media sections, keeping only non-video media
|
||||
parsedSdp.media = parsedSdp.media.filter((media) => media.type !== 'video');
|
||||
return sdpTransform.write(parsedSdp);
|
||||
};
|
||||
|
||||
const extractSdpMedia = (sdp) => {
|
||||
const parsedSdp1 = sdpTransform.parse(sdp);
|
||||
@@ -54,5 +60,6 @@ module.exports = {
|
||||
mergeSdpMedia,
|
||||
extractSdpMedia,
|
||||
isOpusFirst,
|
||||
makeOpusFirst
|
||||
makeOpusFirst,
|
||||
removeVideoSdp
|
||||
};
|
||||
|
||||
70
lib/utils/sticky-event-emitter.js
Normal file
70
lib/utils/sticky-event-emitter.js
Normal file
@@ -0,0 +1,70 @@
|
||||
const EventEmitter = require('events');
|
||||
|
||||
/**
|
||||
* A specialized EventEmitter that caches the most recent event emissions.
|
||||
* When new listeners are added, they immediately receive the most recent
|
||||
* event if it was previously emitted. This is useful for handling state
|
||||
* changes where late subscribers need to know the current state.
|
||||
*
|
||||
* Features:
|
||||
* - Caches the most recent emission for each event type
|
||||
* - New listeners immediately receive the cached event if available
|
||||
* - Supports both regular (on) and one-time (once) listeners
|
||||
* - Maintains compatibility with Node's EventEmitter interface
|
||||
*/
|
||||
class StickyEventEmitter extends EventEmitter {
|
||||
constructor() {
|
||||
super();
|
||||
this._eventCache = new Map();
|
||||
this._onceListeners = new Map(); // For storing once listeners if needed
|
||||
}
|
||||
destroy() {
|
||||
this._eventCache.clear();
|
||||
this._onceListeners.clear();
|
||||
this.removeAllListeners();
|
||||
}
|
||||
emit(event, ...args) {
|
||||
// Store the event and its args
|
||||
this._eventCache.set(event, args);
|
||||
|
||||
// If there are any 'once' listeners waiting, call them
|
||||
if (this._onceListeners.has(event)) {
|
||||
const listeners = this._onceListeners.get(event);
|
||||
for (const listener of listeners) {
|
||||
listener(...args);
|
||||
}
|
||||
if (this.onSuccess) {
|
||||
this.onSuccess();
|
||||
}
|
||||
this._onceListeners.delete(event);
|
||||
}
|
||||
|
||||
return super.emit(event, ...args);
|
||||
}
|
||||
|
||||
on(event, listener) {
|
||||
if (this._eventCache.has(event)) {
|
||||
listener(...this._eventCache.get(event));
|
||||
}
|
||||
return super.on(event, listener);
|
||||
}
|
||||
|
||||
once(event, listener) {
|
||||
if (this._eventCache.has(event)) {
|
||||
listener(...this._eventCache.get(event));
|
||||
if (this.onSuccess) {
|
||||
this.onSuccess();
|
||||
}
|
||||
} else {
|
||||
// Store listener in case emit comes before
|
||||
if (!this._onceListeners.has(event)) {
|
||||
this._onceListeners.set(event, []);
|
||||
}
|
||||
this._onceListeners.get(event).push(listener);
|
||||
super.once(event, listener); // Also attach to native once
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
module.exports = StickyEventEmitter;
|
||||
@@ -117,7 +117,16 @@ const stickyVars = {
|
||||
'SPEECHMATICS_SPEECH_HINTS',
|
||||
'SPEECHMATICS_TRANSLATION_LANGUAGES',
|
||||
'SPEECHMATICS_TRANSLATION_PARTIALS'
|
||||
]
|
||||
],
|
||||
openai: [
|
||||
'OPENAI_API_KEY',
|
||||
'OPENAI_MODEL',
|
||||
'OPENAI_INPUT_AUDIO_NOISE_REDUCTION',
|
||||
'OPENAI_TURN_DETECTION_TYPE',
|
||||
'OPENAI_TURN_DETECTION_THRESHOLD',
|
||||
'OPENAI_TURN_DETECTION_PREFIX_PADDING_MS',
|
||||
'OPENAI_TURN_DETECTION_SILENCE_DURATION_MS',
|
||||
],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -304,13 +313,18 @@ const normalizeDeepgram = (evt, channel, language, shortUtterance) => {
|
||||
confidence: alt.confidence,
|
||||
transcript: alt.transcript,
|
||||
}));
|
||||
|
||||
/**
|
||||
* Some models (nova-2-general) return the detected language in the
|
||||
* alternatives.languages array if the language is set as multi.
|
||||
* If the language is detected, we use it as the language_code.
|
||||
*/
|
||||
const detectedLanguage = evt.channel?.alternatives?.[0]?.languages?.[0];
|
||||
/**
|
||||
* note difference between is_final and speech_final in Deepgram:
|
||||
* https://developers.deepgram.com/docs/understand-endpointing-interim-results
|
||||
*/
|
||||
return {
|
||||
language_code: language,
|
||||
language_code: detectedLanguage || language,
|
||||
channel_tag: channel,
|
||||
is_final: shortUtterance ? evt.is_final : evt.speech_final,
|
||||
alternatives: alternatives.length ? [alternatives[0]] : [],
|
||||
@@ -566,6 +580,35 @@ const normalizeSpeechmatics = (evt, channel, language) => {
|
||||
return obj;
|
||||
};
|
||||
|
||||
const calculateConfidence = (logprobsArray) => {
|
||||
// Sum the individual log probabilities
|
||||
const totalLogProb = logprobsArray.reduce((sum, tokenInfo) => sum + tokenInfo.logprob, 0);
|
||||
|
||||
// Convert the total log probability back to a regular probability
|
||||
const confidence = Math.exp(totalLogProb);
|
||||
return confidence;
|
||||
};
|
||||
|
||||
const normalizeOpenAI = (evt, channel, language) => {
|
||||
const copy = JSON.parse(JSON.stringify(evt));
|
||||
const obj = {
|
||||
language_code: language,
|
||||
channel_tag: channel,
|
||||
is_final: true,
|
||||
alternatives: [
|
||||
{
|
||||
transcript: evt.transcript,
|
||||
confidence: evt.logprobs ? calculateConfidence(evt.logprobs) : 1.0,
|
||||
}
|
||||
],
|
||||
vendor: {
|
||||
name: 'openai',
|
||||
evt: copy
|
||||
}
|
||||
};
|
||||
return obj;
|
||||
};
|
||||
|
||||
module.exports = (logger) => {
|
||||
const normalizeTranscription = (evt, vendor, channel, language, shortUtterance, punctuation) => {
|
||||
|
||||
@@ -597,6 +640,8 @@ module.exports = (logger) => {
|
||||
return normalizeVerbio(evt, channel, language);
|
||||
case 'speechmatics':
|
||||
return normalizeSpeechmatics(evt, channel, language);
|
||||
case 'openai':
|
||||
return normalizeOpenAI(evt, channel, language);
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
return normalizeCustom(evt, channel, language, vendor);
|
||||
@@ -784,7 +829,7 @@ module.exports = (logger) => {
|
||||
};
|
||||
}
|
||||
else if ('deepgram' === vendor) {
|
||||
let {model} = rOpts;
|
||||
let model = rOpts.deepgramOptions?.model || rOpts.model;
|
||||
const {deepgramOptions = {}} = rOpts;
|
||||
const deepgramUri = deepgramOptions.deepgramSttUri || sttCredentials.deepgram_stt_uri;
|
||||
const useTls = deepgramOptions.deepgramSttUseTls || sttCredentials.deepgram_stt_use_tls;
|
||||
@@ -963,6 +1008,36 @@ module.exports = (logger) => {
|
||||
{VOXIST_API_KEY: sttCredentials.api_key},
|
||||
};
|
||||
}
|
||||
else if ('openai' === vendor) {
|
||||
const {openaiOptions = {}} = rOpts;
|
||||
const model = openaiOptions.model || rOpts.model || sttCredentials.model_id || 'whisper-1';
|
||||
const apiKey = openaiOptions.apiKey || sttCredentials.api_key;
|
||||
|
||||
opts = {
|
||||
OPENAI_MODEL: model,
|
||||
OPENAI_API_KEY: apiKey,
|
||||
...opts,
|
||||
...(openaiOptions.prompt && {OPENAI_PROMPT: openaiOptions.prompt}),
|
||||
...(openaiOptions.input_audio_noise_reduction &&
|
||||
{OPENAI_INPUT_AUDIO_NOISE_REDUCTION: openaiOptions.input_audio_noise_reduction}),
|
||||
};
|
||||
|
||||
if (openaiOptions.turn_detection) {
|
||||
opts = {
|
||||
...opts,
|
||||
OPENAI_TURN_DETECTION_TYPE: openaiOptions.turn_detection.type,
|
||||
...(openaiOptions.turn_detection.threshold && {
|
||||
OPENAI_TURN_DETECTION_THRESHOLD: openaiOptions.turn_detection.threshold
|
||||
}),
|
||||
...(openaiOptions.turn_detection.prefix_padding_ms && {
|
||||
OPENAI_TURN_DETECTION_PREFIX_PADDING_MS: openaiOptions.turn_detection.prefix_padding_ms
|
||||
}),
|
||||
...(openaiOptions.turn_detection.silence_duration_ms && {
|
||||
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
|
||||
}),
|
||||
};
|
||||
}
|
||||
}
|
||||
else if ('verbio' === vendor) {
|
||||
const {verbioOptions = {}} = rOpts;
|
||||
opts = {
|
||||
|
||||
@@ -8,7 +8,7 @@ const {
|
||||
const MAX_CHUNK_SIZE = 1800;
|
||||
const HIGH_WATER_BUFFER_SIZE = 1000;
|
||||
const LOW_WATER_BUFFER_SIZE = 200;
|
||||
const TIMEOUT_RETRY_MSECS = 3000;
|
||||
const TIMEOUT_RETRY_MSECS = 1000; // 1 second
|
||||
|
||||
|
||||
const isWhitespace = (str) => /^\s*$/.test(str);
|
||||
@@ -377,6 +377,7 @@ class TtsStreamingBuffer extends Emitter {
|
||||
|
||||
_onTimeout() {
|
||||
this.logger.debug('TtsStreamingBuffer:_onTimeout Timeout waiting for sentence boundary');
|
||||
this.timer = null;
|
||||
// Check if new text has been added since the timer was set.
|
||||
const now = Date.now();
|
||||
if (now - this.lastUpdateTime < TIMEOUT_RETRY_MSECS) {
|
||||
@@ -384,7 +385,6 @@ class TtsStreamingBuffer extends Emitter {
|
||||
this._setTimerIfNeeded();
|
||||
return;
|
||||
}
|
||||
this.timer = null;
|
||||
this._feedQueue(true);
|
||||
}
|
||||
|
||||
|
||||
@@ -132,7 +132,7 @@ class WsRequestor extends BaseRequestor {
|
||||
assert(this.ws);
|
||||
|
||||
/* prepare and send message */
|
||||
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip']) : null;
|
||||
let payload = params ? snakeCaseKeys(params, ['customerData', 'sip', 'env_vars', 'args']) : null;
|
||||
if (type === 'session:new' || type === 'session:adulting') this._sessionData = payload;
|
||||
if (type === 'session:reconnect') payload = this._sessionData;
|
||||
assert.ok(url, 'WsRequestor:request url was not provided');
|
||||
@@ -146,7 +146,9 @@ class WsRequestor extends BaseRequestor {
|
||||
type,
|
||||
msgid,
|
||||
call_sid: this.call_sid,
|
||||
hook: ['verb:hook', 'session:redirect', 'llm:event', 'llm:tool-call'].includes(type) ? url : undefined,
|
||||
hook: [
|
||||
'verb:hook', 'dial:confirm', 'session:redirect', 'llm:event', 'llm:tool-call'
|
||||
].includes(type) ? url : undefined,
|
||||
data: {...payload},
|
||||
...b3
|
||||
};
|
||||
|
||||
982
package-lock.json
generated
982
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
13
package.json
13
package.json
@@ -27,14 +27,15 @@
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-auto-scaling": "^3.549.0",
|
||||
"@aws-sdk/client-sns": "^3.549.0",
|
||||
"@jambonz/db-helpers": "^0.9.6",
|
||||
"@jambonz/db-helpers": "^0.9.12",
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.13",
|
||||
"@jambonz/speech-utils": "^0.2.3",
|
||||
"@jambonz/speech-utils": "^0.2.10",
|
||||
"@jambonz/stats-collector": "^0.1.10",
|
||||
"@jambonz/verb-specifications": "^0.0.98",
|
||||
"@jambonz/time-series": "^0.2.13",
|
||||
"@jambonz/verb-specifications": "^0.0.104",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
"@opentelemetry/exporter-trace-otlp-http": "^0.50.0",
|
||||
@@ -47,8 +48,8 @@
|
||||
"bent": "^7.3.12",
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^4.0.1",
|
||||
"drachtio-srf": "^5.0.2",
|
||||
"drachtio-fsmrf": "^4.0.3",
|
||||
"drachtio-srf": "^5.0.5",
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"moment": "^2.30.1",
|
||||
@@ -60,7 +61,7 @@
|
||||
"short-uuid": "^5.1.0",
|
||||
"sinon": "^17.0.1",
|
||||
"to-snake-case": "^1.0.0",
|
||||
"undici": "^6.20.0",
|
||||
"undici": "^7.5.0",
|
||||
"uuid-random": "^1.3.2",
|
||||
"verify-aws-sns-signature": "^0.1.0",
|
||||
"ws": "^8.18.0",
|
||||
|
||||
Reference in New Issue
Block a user