mirror of
https://github.com/jambonz/jambonz-feature-server.git
synced 2026-02-11 00:39:56 +00:00
Compare commits
14 Commits
v0.9.5-9
...
v0.9.6-rc2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
45d0ca87af | ||
|
|
bd435dfff9 | ||
|
|
b598cd94ae | ||
|
|
ceb9a7a3bd | ||
|
|
ff5f9acaf8 | ||
|
|
96cdc2936b | ||
|
|
6120dcbe96 | ||
|
|
96d72216e2 | ||
|
|
faee30278b | ||
|
|
325af42946 | ||
|
|
9848152d5b | ||
|
|
2468557aef | ||
|
|
3c3dfa81d3 | ||
|
|
961c2589ac |
@@ -1082,7 +1082,7 @@ class CallSession extends Emitter {
|
||||
const cred = JSON.parse(credential.service_key.replace(/\n/g, '\\n'));
|
||||
return {
|
||||
speech_credential_sid: credential.speech_credential_sid,
|
||||
credentials: cred
|
||||
credentials: cred,
|
||||
};
|
||||
} catch (err) {
|
||||
const sid = this.accountInfo.account.account_sid;
|
||||
@@ -2028,7 +2028,7 @@ Duration=${duration} `
|
||||
return this._lccDub(opts.dub, callSid);
|
||||
}
|
||||
else if (opts.boostAudioSignal) {
|
||||
return this._lccBoostAudioSignal(opts, callSid);
|
||||
return this._lccBoostAudioSignal(opts.boostAudioSignal, callSid);
|
||||
}
|
||||
else if (opts.media_path) {
|
||||
return this._lccMediaPath(opts.media_path, callSid);
|
||||
@@ -2500,6 +2500,36 @@ Duration=${duration} `
|
||||
}
|
||||
else {
|
||||
this.logger.error(err, `Error attempting to allocate endpoint for for task ${task.name}`);
|
||||
// Check for SipError type (e.g., 488 codec incompatibility)
|
||||
const isSipError = err.name === 'SipError';
|
||||
if (isSipError && err.status) {
|
||||
// Extract Reason header from SIP response if available (e.g., Q.850;cause=88;text="INCOMPATIBLE_DESTINATION")
|
||||
const sipReasonHeader = err.res?.msg?.headers?.reason;
|
||||
this._endpointAllocationError = {
|
||||
status: err.status,
|
||||
reason: err.reason || 'Endpoint Allocation Failed',
|
||||
sipReasonHeader
|
||||
};
|
||||
this.logger.info({endpointAllocationError: this._endpointAllocationError},
|
||||
'Captured SipError for propagation to SBC');
|
||||
|
||||
// Send SIP error response immediately for inbound calls
|
||||
if (this.res && !this.res.finalResponseSent) {
|
||||
this.logger.info(`Sending ${err.status} response to SBC due to SipError`);
|
||||
this.res.send(err.status, {
|
||||
headers: {
|
||||
'X-Reason': `endpoint allocation failure: ${err.reason || 'Endpoint Allocation Failed'}`,
|
||||
...(sipReasonHeader && {'Reason': sipReasonHeader})
|
||||
}
|
||||
});
|
||||
this._notifyCallStatusChange({
|
||||
callStatus: CallStatus.Failed,
|
||||
sipStatus: err.status,
|
||||
sipReason: err.reason || 'Endpoint Allocation Failed'
|
||||
});
|
||||
this._callReleased();
|
||||
}
|
||||
}
|
||||
throw new Error(`${BADPRECONDITIONS}: unable to allocate endpoint`);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -60,6 +60,19 @@ class InboundCallSession extends CallSession {
|
||||
}
|
||||
});
|
||||
}
|
||||
else if (this._endpointAllocationError) {
|
||||
// Propagate SIP error from endpoint allocation failure back to the client
|
||||
const {status, reason, sipReasonHeader} = this._endpointAllocationError;
|
||||
this.rootSpan.setAttributes({'call.termination': `endpoint allocation SIP error ${status}`});
|
||||
this.logger.info({endpointAllocationError: this._endpointAllocationError},
|
||||
`InboundCallSession:_onTasksDone generating ${status} due to endpoint allocation failure`);
|
||||
this.res.send(status, {
|
||||
headers: {
|
||||
'X-Reason': `endpoint allocation failure: ${reason}`,
|
||||
...(sipReasonHeader && {'Reason': sipReasonHeader})
|
||||
}
|
||||
});
|
||||
}
|
||||
else {
|
||||
this.rootSpan.setAttributes({'call.termination': 'tasks completed without answering call'});
|
||||
this.logger.info('InboundCallSession:_onTasksDone auto-generating non-success response to invite');
|
||||
|
||||
@@ -195,6 +195,9 @@ class TaskDial extends Task {
|
||||
async exec(cs) {
|
||||
await super.exec(cs);
|
||||
|
||||
/* capture whether A leg was already answered before this dial task started */
|
||||
this._aLegAlreadyAnswered = !!cs.dlg;
|
||||
|
||||
if (this.data.anchorMedia && this.data.exitMediaPath) {
|
||||
this.logger.info('Dial:exec - incompatible anchorMedia and exitMediaPath are both set, will obey anchorMedia');
|
||||
delete this.data.exitMediaPath;
|
||||
@@ -550,7 +553,7 @@ class TaskDial extends Task {
|
||||
let sbcAddress = this.proxy || getSBC();
|
||||
const teamsInfo = {};
|
||||
let fqdn;
|
||||
const forwardPAI = this.forwardPAI ?? JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
|
||||
const forwardPAI = this.forwardPAI ?? !JAMBONZ_DIAL_PAI_HEADER; // dial verb overides env var
|
||||
this.logger.debug(forwardPAI, 'forwardPAI value');
|
||||
if (!sbcAddress) throw new Error('no SBC found for outbound call');
|
||||
this.headers = {
|
||||
@@ -872,8 +875,12 @@ class TaskDial extends Task {
|
||||
this.sd = sd;
|
||||
this.callSid = sd.callSid;
|
||||
if (this.earlyMedia) {
|
||||
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
||||
await cs.propagateAnswer();
|
||||
if (this._aLegAlreadyAnswered) {
|
||||
debug('Dial:_selectSingleDial A leg was already answered, skipping propagateAnswer');
|
||||
} else {
|
||||
debug('Dial:_selectSingleDial propagating answer supervision on A leg now that B is connected');
|
||||
await cs.propagateAnswer();
|
||||
}
|
||||
}
|
||||
if (this.timeLimit) {
|
||||
this.timerMaxCallDuration = setTimeout(this._onMaxCallDuration.bind(this, cs), this.timeLimit * 1000);
|
||||
|
||||
@@ -152,9 +152,17 @@ class TaskListen extends Task {
|
||||
|
||||
async _startListening(cs, ep) {
|
||||
this._initListeners(ep);
|
||||
const ci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
|
||||
const tempci = this.nested ? this.parentTask.sd.callInfo : cs.callInfo.toJSON();
|
||||
const ci = structuredClone(tempci);
|
||||
if (this._ignoreCustomerData) {
|
||||
delete ci.customerData;
|
||||
} else {
|
||||
for (const key in ci.customerData) {
|
||||
if (ci.customerData.hasOwnProperty(key)) {
|
||||
const value = ci.customerData[key];
|
||||
ci.customerData[key] = typeof value === 'string' ? escapeString(value) : value;
|
||||
}
|
||||
}
|
||||
}
|
||||
const metadata = Object.assign(
|
||||
{sampleRate: this.sampleRate, mixType: this.mixType},
|
||||
|
||||
@@ -36,6 +36,9 @@ class TaskLlmGoogle_S2S extends Task {
|
||||
this.model = this.parent.model || 'models/gemini-2.0-flash-live-001';
|
||||
this.auth = this.parent.auth;
|
||||
this.connectionOptions = this.parent.connectOptions;
|
||||
const {host, version} = this.connectionOptions || {};
|
||||
this.host = host;
|
||||
this.version = version;
|
||||
|
||||
const {apiKey} = this.auth || {};
|
||||
if (!apiKey) throw new Error('auth.apiKey is required for Google S2S');
|
||||
@@ -46,7 +49,7 @@ class TaskLlmGoogle_S2S extends Task {
|
||||
this.eventHook = this.data.eventHook;
|
||||
this.toolHook = this.data.toolHook;
|
||||
|
||||
const {setup} = this.data.llmOptions;
|
||||
const {setup, sessionResumption} = this.data.llmOptions;
|
||||
|
||||
if (typeof setup !== 'object') {
|
||||
throw new Error('llmOptions with an initial setup is required for Google S2S');
|
||||
@@ -54,6 +57,7 @@ class TaskLlmGoogle_S2S extends Task {
|
||||
this.setup = {
|
||||
...setup,
|
||||
model: this.model,
|
||||
...(sessionResumption && {sessionResumption}),
|
||||
// make sure output is always audio
|
||||
generationConfig: {
|
||||
...(setup.generationConfig || {}),
|
||||
@@ -138,6 +142,10 @@ class TaskLlmGoogle_S2S extends Task {
|
||||
|
||||
try {
|
||||
const args = [ep.uuid, 'session.create', this.apiKey];
|
||||
if (this.host) {
|
||||
args.push(this.host);
|
||||
if (this.version) args.push(this.version);
|
||||
}
|
||||
await this._api(ep, args);
|
||||
} catch (err) {
|
||||
this.logger.error({err}, 'TaskLlmGoogle_S2S:_startListening');
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
const Task = require('./task');
|
||||
const {TaskName} = require('../utils/constants');
|
||||
const WsRequestor = require('../utils/ws-requestor');
|
||||
const URL = require('url');
|
||||
const HttpRequestor = require('../utils/http-requestor');
|
||||
|
||||
/**
|
||||
@@ -10,6 +9,7 @@ const HttpRequestor = require('../utils/http-requestor');
|
||||
class TaskRedirect extends Task {
|
||||
constructor(logger, opts) {
|
||||
super(logger, opts);
|
||||
this.statusHook = opts.statusHook || false;
|
||||
}
|
||||
|
||||
get name() { return TaskName.Redirect; }
|
||||
@@ -47,6 +47,30 @@ class TaskRedirect extends Task {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/* update the notifier if a new statusHook was provided */
|
||||
if (this.statusHook) {
|
||||
this.logger.info(`TaskRedirect updating statusHook to ${this.statusHook}`);
|
||||
try {
|
||||
const oldNotifier = cs.application.notifier;
|
||||
const isStatusHookAbsolute = cs.notifier?._isAbsoluteUrl(this.statusHook);
|
||||
if (isStatusHookAbsolute) {
|
||||
if (cs.notifier instanceof WsRequestor) {
|
||||
cs.application.notifier = new WsRequestor(this.logger, cs.accountSid, {url: this.statusHook},
|
||||
cs.accountInfo.account.webhook_secret);
|
||||
} else {
|
||||
cs.application.notifier = new HttpRequestor(this.logger, cs.accountSid, {url: this.statusHook},
|
||||
cs.accountInfo.account.webhook_secret);
|
||||
}
|
||||
if (oldNotifier?.close) oldNotifier.close();
|
||||
}
|
||||
/* update the call_status_hook URL that gets passed to the notifier */
|
||||
cs.application.call_status_hook = this.statusHook;
|
||||
} catch (err) {
|
||||
this.logger.info(err, `TaskRedirect error updating statusHook to ${this.statusHook}`);
|
||||
}
|
||||
}
|
||||
|
||||
await this.performAction();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -31,8 +31,9 @@ class TtsTask extends Task {
|
||||
this.synthesizer = this.data.synthesizer || {};
|
||||
this.disableTtsCache = this.data.disableTtsCache;
|
||||
this.options = this.synthesizer.options || {};
|
||||
this.instructions = this.data.instructions;
|
||||
this.instructions = this.data.instructions || this.options.instructions;
|
||||
this.playbackIds = [];
|
||||
this.useGeminiTts = this.options.useGeminiTts;
|
||||
}
|
||||
|
||||
getPlaybackId(offset) {
|
||||
@@ -156,6 +157,13 @@ class TtsTask extends Task {
|
||||
...(reduceLatency && {RIMELABS_TTS_STREAMING_REDUCE_LATENCY: reduceLatency})
|
||||
};
|
||||
break;
|
||||
case 'google':
|
||||
obj = {
|
||||
GOOGLE_TTS_LANGUAGE_CODE: language,
|
||||
GOOGLE_TTS_VOICE_NAME: voice,
|
||||
GOOGLE_APPLICATION_CREDENTIALS: JSON.stringify(credentials.credentials)
|
||||
};
|
||||
break;
|
||||
default:
|
||||
if (vendor.startsWith('custom:')) {
|
||||
const use_tls = custom_tts_streaming_url.startsWith('wss://');
|
||||
@@ -242,6 +250,8 @@ class TtsTask extends Task {
|
||||
}
|
||||
} else if (vendor === 'cartesia') {
|
||||
credentials.model_id = this.options.model_id || credentials.model_id;
|
||||
} else if (vendor === 'google') {
|
||||
this.model = this.options.model || credentials.credentials.model_id;
|
||||
}
|
||||
|
||||
this.model_id = credentials.model_id;
|
||||
|
||||
@@ -118,6 +118,13 @@ class ActionHookDelayProcessor extends Emitter {
|
||||
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer');
|
||||
this._noResponseTimer = null;
|
||||
|
||||
/* check if endpoint is still available (call may have ended) */
|
||||
if (!this.ep) {
|
||||
this.logger.debug('ActionHookDelayProcessor#_onNoResponseTimer: endpoint is null, call may have ended');
|
||||
this._active = false;
|
||||
return;
|
||||
}
|
||||
|
||||
/* get the next play or say action */
|
||||
const verb = this.actions[this._retryCount % this.actions.length];
|
||||
|
||||
@@ -129,8 +136,8 @@ class ActionHookDelayProcessor extends Emitter {
|
||||
this._taskInProgress.exec(this.cs, {ep: this.ep}).catch((err) => {
|
||||
this.logger.info(`ActionHookDelayProcessor#_onNoResponseTimer: error playing file: ${err.message}`);
|
||||
this._taskInProgress = null;
|
||||
this.ep.removeAllListeners('playback-start');
|
||||
this.ep.removeAllListeners('playback-stop');
|
||||
this.ep?.removeAllListeners('playback-start');
|
||||
this.ep?.removeAllListeners('playback-stop');
|
||||
});
|
||||
} catch (err) {
|
||||
this.logger.info(err, 'ActionHookDelayProcessor#_onNoResponseTimer: error starting action');
|
||||
|
||||
@@ -311,6 +311,11 @@
|
||||
"ConnectFailure": "deepgram_tts_streaming::connect_failed",
|
||||
"Connect": "deepgram_tts_streaming::connect"
|
||||
},
|
||||
"GoogleTtsStreamingEvents": {
|
||||
"Empty": "google_tts_streaming::empty",
|
||||
"ConnectFailure": "google_tts_streaming::connect_failed",
|
||||
"Connect": "google_tts_streaming::connect"
|
||||
},
|
||||
"CartesiaTtsStreamingEvents": {
|
||||
"Empty": "cartesia_tts_streaming::empty",
|
||||
"ConnectFailure": "cartesia_tts_streaming::connect_failed",
|
||||
|
||||
@@ -1310,6 +1310,9 @@ module.exports = (logger) => {
|
||||
...(openaiOptions.turn_detection.silence_duration_ms && {
|
||||
OPENAI_TURN_DETECTION_SILENCE_DURATION_MS: openaiOptions.turn_detection.silence_duration_ms
|
||||
}),
|
||||
...(openaiOptions.turn_detection.eagerness && {
|
||||
OPENAI_TURN_DETECTION_EAGERNESS: openaiOptions.turn_detection.eagerness
|
||||
})
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1375,7 +1378,9 @@ module.exports = (logger) => {
|
||||
speechmaticsOptions.transcription_config.audio_filtering_config.volume_threshold}),
|
||||
...(speechmaticsOptions.transcription_config?.transcript_filtering_config?.remove_disfluencies &&
|
||||
{SPEECHMATICS_REMOVE_DISFLUENCIES:
|
||||
speechmaticsOptions.transcription_config.transcript_filtering_config.remove_disfluencies})
|
||||
speechmaticsOptions.transcription_config.transcript_filtering_config.remove_disfluencies}),
|
||||
SPEECHMATICS_END_OF_UTTERANCE_SILENCE_TRIGGER:
|
||||
speechmaticsOptions.transcription_config?.conversation_config?.end_of_utterance_silence_trigger || 0.5
|
||||
};
|
||||
}
|
||||
else if (vendor.startsWith('custom:')) {
|
||||
|
||||
@@ -421,6 +421,7 @@ class TtsStreamingBuffer extends Emitter {
|
||||
'cartesia',
|
||||
'elevenlabs',
|
||||
'rimelabs',
|
||||
'google',
|
||||
'custom'
|
||||
].forEach((vendor) => {
|
||||
const eventClassName = `${vendor.charAt(0).toUpperCase() + vendor.slice(1)}TtsStreamingEvents`;
|
||||
|
||||
1973
package-lock.json
generated
1973
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,10 @@
|
||||
"@jambonz/http-health-check": "^0.0.1",
|
||||
"@jambonz/mw-registrar": "^0.2.7",
|
||||
"@jambonz/realtimedb-helpers": "^0.8.15",
|
||||
"@jambonz/speech-utils": "^0.2.26",
|
||||
"@jambonz/speech-utils": "^0.2.30",
|
||||
"@jambonz/stats-collector": "^0.1.10",
|
||||
"@jambonz/time-series": "^0.2.15",
|
||||
"@jambonz/verb-specifications": "^0.0.123",
|
||||
"@jambonz/verb-specifications": "^0.0.125",
|
||||
"@modelcontextprotocol/sdk": "^1.9.0",
|
||||
"@opentelemetry/api": "^1.8.0",
|
||||
"@opentelemetry/exporter-jaeger": "^1.23.0",
|
||||
@@ -49,7 +49,7 @@
|
||||
"debug": "^4.3.4",
|
||||
"deepcopy": "^2.1.0",
|
||||
"drachtio-fsmrf": "^4.1.2",
|
||||
"drachtio-srf": "^5.0.14",
|
||||
"drachtio-srf": "^5.0.18",
|
||||
"express": "^4.19.2",
|
||||
"express-validator": "^7.0.1",
|
||||
"moment": "^2.30.1",
|
||||
|
||||
Reference in New Issue
Block a user