wip: implemented listen, transcribe, play

This commit is contained in:
Dave Horton
2020-01-17 09:15:23 -05:00
parent 1a656f3f0e
commit 0d4c1d9d8c
24 changed files with 688 additions and 108 deletions

View File

@@ -9,6 +9,7 @@ const makeTask = require('./tasks/make_task');
const resourcesMixin = require('./utils/resources');
const moment = require('moment');
const assert = require('assert');
const Dialog = require('drachtio-srf').Dialog;
const BADPRECONDITIONS = 'preconditions not met';
class CallSession extends Emitter {
@@ -34,27 +35,34 @@ class CallSession extends Emitter {
this.hooks = notifiers(this.logger, this.callAttributes);
req.on('cancel', this._onCallerHangup.bind(this));
this.callGone = false;
req.on('cancel', this._onCallerHangup.bind(this, req));
this.on('callStatusChange', this._onCallStatusChange.bind(this));
}
get callSid() { return this.callAttributes.CallSid; }
get parentCallSid() { return this.callAttributes.CallSid; }
get actionHook() { return this.hooks.actionHook; }
get callingNumber() { return this.req.callingNumber; }
get calledNumber() { return this.req.calledNumber; }
async exec() {
let idx = 0;
while (this._executionStack.length) {
const taskList = this.currentTaskList = this._executionStack.shift();
this.logger.debug(`CallSession:exec starting task list with ${taskList.tasks.length} tasks`);
while (taskList.length) {
while (taskList.length && !this.callGone) {
const {task, callSid} = taskList.shift();
this.logger.debug(`CallSession:exec starting task #${++idx}: ${task.name}`);
try {
const resources = await this._evaluatePreconditions(task, callSid);
this.currentTask = task;
await task.exec(this, resources);
this.currentTask = null;
this.logger.debug(`CallSession:exec completed task #${idx}: ${task.name}`);
} catch (err) {
this.currentTask = null;
if (err.message.includes(BADPRECONDITIONS)) {
this.logger.info(`CallSession:exec task #${idx}: ${task.name}: ${err.message}`);
}
@@ -92,6 +100,7 @@ class CallSession extends Emitter {
}
async _evalEndpointPrecondition(task, callSid) {
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
const resources = this.calls.get(callSid);
if (!resources) throw new Error(`task ${task.name} attempting to operate on unknown CallSid ${callSid}`);
if (resources.ep) return resources.ep;
@@ -105,6 +114,7 @@ class CallSession extends Emitter {
this.addResource('ms', ms);
}
const ep = await ms.createEndpoint({remoteSdp: this.req.body});
ep.cs = this;
resources.ep = ep;
if (task.earlyMedia && callSid === this.parentCallSid && this.req && !this.req.finalResponseSent) {
this.res.send(183, {body: ep.local.sdp});
@@ -112,7 +122,10 @@ class CallSession extends Emitter {
return ep;
}
const uas = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp});
uas.on('destroy', this._onCallerHangup.bind(this, uas));
uas.callSid = callSid;
resources.dlg = uas;
this.logger.debug(`CallSession:_evalEndpointPrecondition - call was answered with callSid ${callSid}`);
this.calls.set(callSid, resources);
return ep;
} catch (err) {
@@ -122,13 +135,15 @@ class CallSession extends Emitter {
}
_evalStableCallPrecondition(task, callSid) {
if (this.callGone) throw new Error(`${BADPRECONDITIONS}: call gone`);
const resources = this.calls.get(callSid);
if (!resources) throw new Error(`task ${task.name} attempting to operate on unknown callSid ${callSid}`);
if (resources.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered - callSid ${callSid}`);
if (!resources.dlg) throw new Error(`${BADPRECONDITIONS}: call was not answered - callSid ${callSid}`);
return resources.dlg;
}
_evalUnansweredCallPrecondition(task, callSid) {
if (this.callGone) new Error(`${BADPRECONDITIONS}: call gone`);
if (callSid !== this.parentCallSid || !this.req) {
throw new Error(`${BADPRECONDITIONS}: no inbound call - callSid ${callSid}`);
}
@@ -152,6 +167,11 @@ class CallSession extends Emitter {
this.calls.clear();
}
/**
* These below methods are needed mainly by the dial verb, which
* deals with a variety of scenarios that can't simply be
* described by the precondition concept as other verbs can
*/
/**
* retrieve the media server and endpoint for this call, allocate them if needed
@@ -208,6 +228,7 @@ class CallSession extends Emitter {
return {ep, ms, res: this.res};
}
const dlg = await this.srf.createUAS(this.req, this.res, {localSdp: ep.local.sdp});
this.logger.debug(`CallSession:connectInboundCallToIvr - answered callSid ${this.parentCallSid}`);
this.calls.set(this.parentCallSid, {ep, dlg});
return {ep, ms, dlg};
} catch (err) {
@@ -265,10 +286,25 @@ class CallSession extends Emitter {
}
/**
* got CANCEL from inbound leg
* got CANCEL or BYE from inbound leg
*/
_onCallerHangup(evt) {
this.logger.debug('CallSession: caller hung before connection');
_onCallerHangup(obj, evt) {
this.callGone = true;
if (obj instanceof Dialog) {
this.logger.debug('CallSession: caller hung up');
/* cant destroy endpoint as current task may need to get final transcription
const resources = this.calls.get(obj.callSid);
if (resources.ep && resources.ep.connected) {
resources.ep.destroy();
resources.ep = null;
this.calls.set(obj.callSid, resources);
}
*/
}
else {
this.logger.debug('CallSession: caller hung before answer');
}
if (this.currentTask) this.currentTask.kill();
}
/**

View File

@@ -4,6 +4,7 @@ const request = require('request');
//require('request-debug')(request);
const uuidv4 = require('uuid/v4');
const makeTask = require('./tasks/make_task');
const normalizeJamones = require('./utils/normalize-jamones');
const {CallStatus, CallDirection} = require('./utils/constants');
module.exports = function(srf, logger) {
@@ -68,19 +69,21 @@ module.exports = function(srf, logger) {
const call_sid = uuidv4();
const method = (app.hook_http_method || 'POST').toUpperCase();
const from = req.getParsedHeader('From');
const qs = req.locals.callAttributes = {
req.locals.callAttributes = {
CallSid: call_sid,
AccountSid: app.account_sid,
From: req.callingNumber,
To: req.calledNumber,
CallStatus: CallStatus.Trying,
SipStatus: 100,
Direction: CallDirection.Inbound,
CallerName: from.name || req.callingNumber,
SipCallID: req.get('Call-ID'),
SipCallID: req.get('Call-ID')
};
const qs = Object.assign({}, req.locals.callAttributes, {
CallStatus: CallStatus.Trying,
SipStatus: 100,
RequestorIP: req.get('X-Forwarded-For'),
RequestorName: req.get('X-Originating-Carrier')
};
});
const opts = {
url: app.call_hook,
method,
@@ -88,6 +91,7 @@ module.exports = function(srf, logger) {
qs
};
if (app.hook_basic_auth_user && app.hook_basic_auth_password) {
logger.debug(`using basic auth with ${app.hook_basic_auth_user}:${app.hook_basic_auth_password}`);
Object.assign(opts, {auth: {user: app.hook_basic_auth_user, password: app.hook_basic_auth_password}});
}
if (method === 'POST') Object.assign(opts, {body: req.msg});
@@ -95,26 +99,16 @@ module.exports = function(srf, logger) {
request(opts, (err, response, body) => {
if (err) {
logger.error(err, `Error invoking callback ${app.call_hook}`);
return res.send(603, 'Bad webhook');
return res.send(500, 'Webhook Failure');
}
logger.debug(body, 'application payload');
const taskData = Array.isArray(body) ? body : [body];
app.tasks = [];
for (const t in taskData) {
try {
const task = makeTask(logger, taskData[t]);
app.tasks.push(task);
} catch (err) {
logger.error({err, data: taskData[t]}, `invalid web callback payload: ${err.message}`);
res.send(500, 'Application Error', {
headers: {
'X-Reason': err.message
}
});
break;
}
logger.debug(body, `application payload: ${body}`);
try {
app.tasks = normalizeJamones(logger, body).map((tdata) => makeTask(logger, tdata));
next();
} catch (err) {
logger.error(err, 'Invalid Webhook Response');
res.send(500);
}
if (!res.finalResponseSent) next();
});
} catch (err) {
logger.error(err, 'Error invoking web callback');

View File

@@ -28,10 +28,10 @@ class TaskDial extends Task {
this.timeLimit = opts.timeLimit;
if (opts.listen) {
this.listenTask = makeTask(logger, {'listen': opts.transcribe});
this.listenTask = makeTask(logger, {'listen': opts.listen});
}
if (opts.transcribe) {
this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe});
this.transcribeTask = makeTask(logger, {'transcribe' : opts.transcribe});
}
this.canceled = false;

View File

@@ -1,5 +1,5 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
const makeTask = require('./make_task');
const assert = require('assert');
@@ -19,6 +19,7 @@ class TaskGather extends Task {
this.timeout = (this.timeout || 5) * 1000;
this.language = this.language || 'en-US';
this.digitBuffer = '';
//this._earlyMedia = this.data.earlyMedia === true;
if (this.say) {
this.sayTask = makeTask(this.logger, {say: this.say});
@@ -27,6 +28,11 @@ class TaskGather extends Task {
get name() { return TaskName.Gather; }
get earlyMedia() {
return (this.sayTask && this.sayTask.earlyMedia) ||
(this.playTask && this.playTask.earlyMedia);
}
async exec(cs, ep) {
this.ep = ep;
this.actionHook = cs.actionHook;
@@ -35,33 +41,15 @@ class TaskGather extends Task {
try {
if (this.sayTask) {
this.sayTask.exec(cs, ep); // kicked off, _not_ waiting for it to complete
this.sayTask.on('playDone', this._onPlayDone.bind(this, ep));
this.sayTask.on('playDone', (err) => {
if (this.taskInProgress) this._startTimer();
});
}
else this._startTimer();
if (this.input.includes('speech')) {
const opts = {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'phone_call'
};
if (this.hints) {
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
}
this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`);
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error set'));
ep.addCustomEventListener('google_transcribe::transcription', this._onTranscription.bind(this, ep));
ep.addCustomEventListener('google_transcribe::no_audio_detected', this._onNoAudioDetected.bind(this, ep));
ep.addCustomEventListener('google_transcribe::max_duration_exceeded', this._onMaxDuration.bind(this, ep));
this.logger.debug('starting transcription');
ep.startTranscription({
interim: this.partialResultCallback ? true : false,
language: this.language
}).catch((err) => this.logger.error(err, 'TaskGather:exec error starting transcription'));
await this._initSpeech(ep);
this._startTranscribing(ep);
}
if (this.input.includes('dtmf')) {
@@ -73,10 +61,12 @@ class TaskGather extends Task {
this.logger.error(err, 'TaskGather:exec error');
}
this.taskInProgress = false;
ep.removeAllListeners();
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
}
kill() {
super.kill();
this._killAudio();
this._resolve('killed');
}
@@ -85,12 +75,6 @@ class TaskGather extends Task {
return new Promise((resolve) => this.resolver = resolve);
}
_onPlayDone(ep, err, evt) {
if (err || !this.taskInProgress) return;
this.logger.debug(evt, 'TaskGather:_onPlayDone, starting input timer');
this._startTimer();
}
_onDtmf(ep, evt) {
this.logger.debug(evt, 'TaskGather:_onDtmf');
if (evt.dtmf === this.finishOnKey) this._resolve('dtmf-terminator-key');
@@ -101,6 +85,32 @@ class TaskGather extends Task {
this._killAudio();
}
async _initSpeech(ep) {
const opts = {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_SINGLE_UTTERANCE: true,
GOOGLE_SPEECH_MODEL: 'phone_call'
};
if (this.hints) {
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
}
this.logger.debug(`setting freeswitch vars ${JSON.stringify(opts)}`);
await ep.set(opts)
.catch((err) => this.logger.info(err, 'Error set'));
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(TranscriptionEvents.EndOfUtterance, this._onEndOfUtterance.bind(this, ep));
}
_startTranscribing(ep) {
ep.startTranscription({
interim: this.partialResultCallback ? true : false,
language: this.language
}).catch((err) => this.logger.error(err, 'TaskGather:_startTranscribing error'));
}
_startTimer() {
assert(!this._timeoutTimer);
this._timeoutTimer = setTimeout(() => this._resolve('timeout'), this.timeout);
@@ -123,6 +133,8 @@ class TaskGather extends Task {
_onTranscription(ep, evt) {
this.logger.debug(evt, 'TaskGather:_onTranscription');
if (evt.is_final) {
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
ep.removeCustomEventListener(TranscriptionEvents.EndOfUtterance);
this._resolve('speech', evt);
}
else if (this.partialResultCallback) {
@@ -131,11 +143,9 @@ class TaskGather extends Task {
});
}
}
_onNoAudioDetected(ep, evt) {
this.logger.info(evt, 'TaskGather:_onNoAudioDetected');
}
_onMaxDuration(ep, evt) {
this.logger.info(evt, 'TaskGather:_onMaxDuration');
_onEndOfUtterance(ep, evt) {
this.logger.info(evt, 'TaskGather:_onEndOfUtterance');
this._startTranscribing(ep);
}
_resolve(reason, evt) {

View File

@@ -1,10 +1,12 @@
const Task = require('./task');
const {TaskName} = require('../utils/constants');
const {TaskName, TaskPreconditions} = require('../utils/constants');
class TaskHangup extends Task {
constructor(logger, opts) {
super(logger, opts);
this.headers = this.data.headers || {};
this.preconditions = TaskPreconditions.StableCall;
}
get name() { return TaskName.Hangup; }
@@ -16,7 +18,7 @@ class TaskHangup extends Task {
try {
await dlg.destroy({headers: this.headers});
} catch (err) {
this.logger.error(err, `TaskHangup:exec - Error hanging up call with sip call id ${dlg.sip.callId}`);
this.logger.error(err, 'TaskHangup:exec - Error hanging up call');
}
}
}

131
lib/tasks/listen.js Normal file
View File

@@ -0,0 +1,131 @@
const Task = require('./task');
const {TaskName, TaskPreconditions, ListenEvents} = require('../utils/constants');
const makeTask = require('./make_task');
const assert = require('assert');
class TaskListen extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
[
'url', 'finishOnKey', 'maxLength', 'metadata', 'mixType', 'passDtmf', 'playBeep',
'sampleRate', 'timeout', 'transcribe'
].forEach((k) => this[k] = this.data[k]);
this.mixType = this.mixType || 'mono';
this.sampleRate = this.sampleRate || 8000;
this.earlyMedia = this.data.earlyMedia === true;
if (this.transcribe) {
this.transcribeTask = makeTask(logger, {'transcribe': opts.transcribe});
}
this._dtmfHandler = this._onDtmf.bind(this);
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
}
get name() { return TaskName.Listen; }
async exec(cs, ep) {
this.ep = ep;
try {
if (this.playBeep) await this._playBeep(ep);
if (this.transcribeTask) {
this.logger.debug('TaskListen:exec - starting nested transcribe task');
this.transcribeTask.exec(cs, ep, this); // kicked off, _not_ waiting for it to complete
}
await this._startListening(ep);
await this._completionPromise;
} catch (err) {
this.logger.info(err, `TaskListen:exec - error ${this.url}`);
}
if (this.transcribeTask) this.transcribeTask.kill();
this._removeListeners(ep);
this.listenComplete = true;
this.emit('listenDone');
}
async kill() {
super.kill();
this._clearTimer();
if (this.ep.connected && !this.listenComplete) {
this.listenComplete = true;
if (this.transcribeTask) {
await this.transcribeTask.kill();
this.transcribeTask = null;
}
await this.ep.forkAudioStop()
.catch((err) => this.logger.info(err, 'TaskListen:kill'));
}
this._completionResolver();
}
async _playBeep(ep) {
await ep.play('tone_stream://L=1;%(500, 0, 1500)')
.catch((err) => this.logger.info(err, 'TaskListen:_playBeep Error playing beep'));
}
async _startListening(ep) {
this._initListeners(ep);
await ep.forkAudioStart({
wsUrl: this.url,
mixType: this.mixType,
sampling: this.sampleRate,
metadata: this.metadata
});
if (this.timeout) {
this._timer = setTimeout(() => {
this.logger.debug(`TaskListen:_startListening terminating task due to timeout of ${this.timeout} reached`);
this.kill();
}, this.timeout * 1000);
}
}
_initListeners(ep) {
ep.addCustomEventListener(ListenEvents.Connect, this._onConnect.bind(this, ep));
ep.addCustomEventListener(ListenEvents.ConnectFailure, this._onConnectFailure.bind(this, ep));
ep.addCustomEventListener(ListenEvents.Error, this._onError.bind(this, ep));
if (this.finishOnKey || this.passDtmf) {
ep.on('dtmf', this._dtmfHandler);
}
}
_removeListeners(ep) {
ep.removeCustomEventListener(ListenEvents.Connect);
ep.removeCustomEventListener(ListenEvents.ConnectFailure);
ep.removeCustomEventListener(ListenEvents.Error);
if (this.finishOnKey || this.passDtmf) {
ep.removeListener('dtmf', this._dtmfHandler);
}
}
_onDtmf(evt) {
if (evt.dtmf === this.finishOnKey) {
this.logger.info(`TaskListen:_onDtmf terminating task due to dtmf ${evt.dtmf}`);
this.kill();
}
}
_clearTimer() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
_onConnect(ep) {
this.logger.debug('TaskListen:_onConnect');
}
_onConnectFailure(ep, evt) {
this.logger.info(evt, 'TaskListen:_onConnectFailure');
this._completionResolver();
}
_onError(ep, evt) {
this.logger.info(evt, 'TaskListen:_onError');
this._completionResolver();
}
}
module.exports = TaskListen;

View File

@@ -2,13 +2,15 @@ const Task = require('./task');
const {TaskName} = require('../utils/constants');
const errBadInstruction = new Error('invalid instruction payload');
function makeTask(logger, opts) {
logger.debug(opts, 'makeTask');
if (typeof opts !== 'object' || Array.isArray(opts)) throw errBadInstruction;
const keys = Object.keys(opts);
if (keys.length !== 1) throw errBadInstruction;
function makeTask(logger, obj) {
const keys = Object.keys(obj);
if (!keys || keys.length !== 1) {
throw errBadInstruction;
}
const name = keys[0];
const data = opts[name];
const data = obj[name];
logger.debug(data, `makeTask: ${name}`);
if (typeof data !== 'object') throw errBadInstruction;
Task.validate(name, data);
switch (name) {
case TaskName.SipDecline:
@@ -26,6 +28,12 @@ function makeTask(logger, opts) {
case TaskName.Gather:
const TaskGather = require('./gather');
return new TaskGather(logger, data);
case TaskName.Transcribe:
const TaskTranscribe = require('./transcribe');
return new TaskTranscribe(logger, data);
case TaskName.Listen:
const TaskListen = require('./listen');
return new TaskListen(logger, data);
}
// should never reach

40
lib/tasks/play.js Normal file
View File

@@ -0,0 +1,40 @@
const Task = require('./task');
const {TaskName, TaskPreconditions} = require('../utils/constants');
class TaskPlay extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.url = this.data.url;
this.loop = this.data.loop || 1;
this.earlyMedia = this.data.earlyMedia === true;
this.playComplete = false;
}
get name() { return TaskName.Play; }
async exec(cs, ep) {
this.ep = ep;
try {
while (!this.playComplete && this.loop--) {
await ep.play(this.url);
}
} catch (err) {
this.logger.info(err, `TaskPlay:exec - error playing ${this.url}`);
}
this.playComplete = true;
this.emit('playDone');
}
kill() {
super.kill();
if (this.ep.connected && !this.playComplete) {
this.logger.debug('TaskPlay:kill - killing audio');
this.playComplete = true;
this.ep.api('uuid_break', this.ep.uuid).catch((err) => this.logger.info(err, 'Error killing audio'));
}
}
}
module.exports = TaskPlay;

View File

@@ -31,7 +31,7 @@ class TaskSay extends Task {
text: this.text
});
} catch (err) {
if (err.message !== 'hangup') this.logger.info(err, 'TaskSay:exec error');
this.logger.info(err, 'TaskSay:exec error');
}
this.emit('playDone');
this.sayComplete = true;

View File

@@ -16,11 +16,22 @@
"required": [
]
},
"play": {
"properties": {
"url": "string",
"loop": "number",
"earlyMedia": "boolean"
},
"required": [
"url"
]
},
"say": {
"properties": {
"text": "string",
"loop": "number",
"synthesizer": "#synthesizer"
"synthesizer": "#synthesizer",
"earlyMedia": "boolean"
},
"required": [
"text",
@@ -31,14 +42,12 @@
"properties": {
"action": "string",
"finishOnKey": "string",
"hints": "array",
"input": "array",
"language": "string",
"numDigits": "number",
"partialResultCallback": "string",
"profanityFilter": "boolean",
"speechTimeout": "number",
"timeout": "number",
"recognizer": "#recognizer",
"say": "#say"
},
"required": [
@@ -73,37 +82,34 @@
},
"listen": {
"properties": {
"finishOnKey": "string",
"maxLength": "number",
"metadata": "object",
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
},
"passDtmf": "boolean",
"playBeep": "boolean",
"sampleRate": "number",
"source": {
"type": "string",
"enum": ["parent", "child", "both"]
},
"wsUrl": "string"
"timeout": "number",
"transcribe": "#transcribe",
"url": "string",
"earlyMedia": "boolean"
},
"required": [
"wsUrl",
"sampleRate"
"url"
]
},
"transcribe": {
"properties": {
"action": "string",
"interim": "boolean",
"jsonKey": "string",
"language": "string",
"source": "string",
"vendor": "string"
"recognizer": "#recognizer",
"earlyMedia": "boolean"
},
"required": [
"action",
"jsonKey",
"language"
"recognizer"
]
},
"target": {
@@ -143,6 +149,28 @@
"enum": ["google"]
},
"voice": "string"
}
},
"required": [
"vendor"
]
},
"recognizer": {
"properties": {
"vendor": {
"type": "string",
"enum": ["google"]
},
"language": "string",
"hints": "array",
"profanityFilter": "boolean",
"interim": "boolean",
"mixType": {
"type": "string",
"enum": ["mono", "stereo", "mixed"]
}
},
"required": [
"vendor"
]
}
}

View File

@@ -13,6 +13,12 @@ class Task extends Emitter {
this.preconditions = TaskPreconditions.None;
this.logger = logger;
this.data = data;
this._killInProgress = false;
}
get killed() {
return this._killInProgress;
}
/**
@@ -20,6 +26,8 @@ class Task extends Emitter {
* what to do is up to each type of task
*/
kill() {
this.logger.debug(`${this.name} is being killed`);
this._killInProgress = true;
// no-op
}

106
lib/tasks/transcribe.js Normal file
View File

@@ -0,0 +1,106 @@
const Task = require('./task');
const {TaskName, TaskPreconditions, TranscriptionEvents} = require('../utils/constants');
const assert = require('assert');
class TaskTranscribe extends Task {
constructor(logger, opts) {
super(logger, opts);
this.preconditions = TaskPreconditions.Endpoint;
this.action = this.data.action;
this.language = this.data.language || 'en-US';
this.vendor = this.data.vendor;
this.interim = this.data.interim === true;
this.mixType = this.data.mixType;
this.earlyMedia = this.data.earlyMedia === true;
this._completionPromise = new Promise((resolve) => this._completionResolver = resolve);
}
get name() { return TaskName.Transcribe; }
async exec(cs, ep, parentTask) {
this.ep = ep;
this.actionHook = ep.cs.actionHook;
this.transcribeInProgress = true;
try {
await this._initSpeech(ep);
await this._startTranscribing(ep);
await this._completionPromise;
} catch (err) {
this.logger.info(err, 'TaskTranscribe:exec - error');
}
this.transcribeInProgress = true;
ep.removeCustomEventListener(TranscriptionEvents.Transcription);
}
async kill() {
super.kill();
if (this.ep.connected && this.transcribeInProgress) {
this.ep.stopTranscription().catch((err) => this.logger.info(err, 'Error TaskTranscribe:kill'));
// hangup after 1 sec if we don't get a final transcription
this._timer = setTimeout(() => this._completionResolver(), 1000);
}
else {
this._completionResolver();
}
await this._completionPromise;
}
async _initSpeech(ep) {
const opts = {
GOOGLE_SPEECH_USE_ENHANCED: true,
GOOGLE_SPEECH_MODEL: 'phone_call'
};
if (this.hints) {
Object.assign(opts, {'GOOGLE_SPEECH_HINTS': this.hints.join(',')});
}
if (this.profanityFilter === true) {
Object.assign(opts, {'GOOGLE_SPEECH_PROFANITY_FILTER': true});
}
await ep.set(opts)
.catch((err) => this.logger.info(err, 'TaskTranscribe:_initSpeech error setting fs vars'));
ep.addCustomEventListener(TranscriptionEvents.Transcription, this._onTranscription.bind(this, ep));
ep.addCustomEventListener(TranscriptionEvents.NoAudioDetected, this._onNoAudio.bind(this, ep));
ep.addCustomEventListener(TranscriptionEvents.MaxDurationExceeded, this._onMaxDurationExceeded.bind(this, ep));
}
async _startTranscribing(ep) {
await ep.startTranscription({
interim: this.interim ? true : false,
language: this.language
});
}
_onTranscription(ep, evt) {
this.logger.debug(evt, 'TaskTranscribe:_onTranscription');
this.actionHook(this.action, 'POST', {
Speech: evt
});
if (this.killed) {
this.logger.debug('TaskTranscribe:_onTranscription exiting after receiving final transcription');
this._clearTimer();
this._completionResolver();
}
}
_onNoAudio(ep) {
this.logger.debug('TaskTranscribe:_onNoAudio restarting transcription');
this._startTranscribing(ep);
}
_onMaxDurationExceeded(ep) {
this.logger.debug('TaskTranscribe:_onMaxDurationExceeded restarting transcription');
this._startTranscribing(ep);
}
_clearTimer() {
if (this._timer) {
clearTimeout(this._timer);
this._timer = null;
}
}
}
module.exports = TaskTranscribe;

View File

@@ -32,5 +32,23 @@
"Endpoint": "endpoint",
"StableCall": "stable-call",
"UnansweredCall": "unanswered-call"
},
"TranscriptionEvents": {
"Transcription": "google_transcribe::transcription",
"EndOfUtterance": "google_transcribe::end_of_utterance",
"NoAudioDetected": "google_transcribe::no_audio_detected",
"MaxDurationExceeded": "google_transcribe::max_duration_exceeded"
},
"ListenEvents": {
"Connect": "mod_audio_fork::connect",
"ConnectFailure": "mod_audio_fork::connect_failed",
"Transcription": "mod_audio_fork::transcription",
"Transfer": "mod_audio_fork::transcription",
"PlayAudio": "mod_audio_fork::play_audio",
"KillAudio": "mod_audio_fork::kill_audio",
"Disconnect": "mod_audio_fork::disconnect",
"Error": "mod_audio_fork::error",
"BufferOverrun": "mod_audio_fork::buffer_overrun",
"JsonMessage": "mod_audio_fork::json"
}
}

View File

@@ -0,0 +1,33 @@
function normalizeJambones(logger, obj) {
logger.debug(`normalizeJambones: ${JSON.stringify(obj)}`);
if (!Array.isArray(obj)) throw new Error('invalid JSON: jambones docs must be array');
const document = [];
for (const tdata of obj) {
if (typeof tdata !== 'object') throw new Error('invalid JSON: jambones docs must be array of objects');
if (Object.keys(tdata).length === 1) {
// {'say': {..}}
logger.debug(`pushing ${JSON.stringify(tdata)}`);
document.push(tdata);
}
else if ('verb' in tdata) {
// {verb: 'say', text: 'foo..bar'..}
const name = tdata.verb;
const o = {};
Object.keys(tdata)
.filter((k) => k !== 'verb')
.forEach((k) => o[k] = tdata[k]);
const o2 = {};
o2[name] = o;
document.push(o2);
}
else {
logger.info(tdata, `invalid JSON: invalid verb form, numkeys ${Object.keys(tdata).length}`);
throw new Error('invalid JSON: invalid verb form');
}
}
logger.debug(`returning document with ${document.length} tasks`);
return document;
}
module.exports = normalizeJambones;

View File

@@ -11,8 +11,8 @@ function hooks(logger, callAttributes) {
url,
method,
json: true,
qs: 'GET' === method ? params : null,
body: 'POST' === method ? params : null
qs: 'GET' === method ? params : callAttributes,
body: 'POST' === method ? opts : null
};
logger.debug(`${method} ${url} sending ${JSON.stringify(obj)}`);
return new Promise((resolve, reject) => {

View File

@@ -0,0 +1,55 @@
const Emitter = require('events');
const {CallStatus} = require('./constants');
class SingleDialer extends Emitter {
constructor(logger, opts) {
super();
this.logger = logger;
this.cs = opts.cs;
this.ms = opts.ms;
}
get callState() {
return this._callState;
}
/**
* launch the outdial
*/
exec() {
}
/**
* kill the call in progress, or stable dialog, whichever
*/
async kill() {
}
/**
* execute a jambones application on this call / endpoint
* @param {*} jambones document
*/
async runApp(document) {
}
async _createEndpoint() {
}
async _outdial() {
}
}
function placeOutdial(logger, opts) {
const singleDialer = new SingleDialer(logger, opts);
singleDialer.exec();
return singleDialer;
}
module.exports = placeOutdial;

73
package-lock.json generated
View File

@@ -706,6 +706,50 @@
"resolved": "https://registry.npmjs.org/drachtio-fn-b2b-sugar/-/drachtio-fn-b2b-sugar-0.0.12.tgz",
"integrity": "sha512-FKPAcEMJTYKDrd9DJUCc4VHnY/c65HOO9k8XqVNognF9T02hKEjGuBCM4Da9ipyfiHmVRuECwj0XNvZ361mkVQ=="
},
"drachtio-fsmrf": {
"version": "1.5.11",
"resolved": "https://registry.npmjs.org/drachtio-fsmrf/-/drachtio-fsmrf-1.5.11.tgz",
"integrity": "sha512-4DQ5N0jCQIHYVn06zPw0lH4sq+nCP11NiC0Y680eC2biFeK2UREFzqJg+/tFKeK4yuj89sshMcVx+0cG+x1E8Q==",
"requires": {
"async": "^1.4.2",
"debug": "^2.2.0",
"delegates": "^0.1.0",
"drachtio-modesl": "^1.2.0",
"drachtio-srf": "^4.4.15",
"lodash": "^4.17.15",
"minimist": "^1.2.0",
"moment": "^2.13.0",
"only": "0.0.2",
"sdp-transform": "^2.7.0",
"uuid": "^3.0.0"
},
"dependencies": {
"debug": {
"version": "2.6.9",
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
"integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
"requires": {
"ms": "2.0.0"
}
},
"ms": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
"integrity": "sha1-VgiurfwAvmwpAd9fmGF4jeDVl8g="
}
}
},
"drachtio-modesl": {
"version": "1.2.0",
"resolved": "https://registry.npmjs.org/drachtio-modesl/-/drachtio-modesl-1.2.0.tgz",
"integrity": "sha512-nkua3RfYnT32OvglERO2zWzJZAfQooZIarZVVAye+iGqTwYJ69X7bU7du5SBHz/jBl+LgeWITMP2fMe2TelxmA==",
"requires": {
"debug": "^4.1.1",
"eventemitter2": "^4.1",
"uuid": "^3.1.0",
"xml2js": "^0.4.19"
}
},
"drachtio-mw-registration-parser": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/drachtio-mw-registration-parser/-/drachtio-mw-registration-parser-0.0.2.tgz",
@@ -959,6 +1003,11 @@
"integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==",
"dev": true
},
"eventemitter2": {
"version": "4.1.2",
"resolved": "https://registry.npmjs.org/eventemitter2/-/eventemitter2-4.1.2.tgz",
"integrity": "sha1-DhqEd6+CGm7zmVsxG/dMI6UkfxU="
},
"events-to-array": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/events-to-array/-/events-to-array-1.1.2.tgz",
@@ -2762,6 +2811,16 @@
"resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz",
"integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg=="
},
"sax": {
"version": "1.2.4",
"resolved": "https://registry.npmjs.org/sax/-/sax-1.2.4.tgz",
"integrity": "sha512-NqVDv9TpANUjFm0N8uM5GxL36UgKi9/atZw+x7YFnQ8ckwFGKrl4xX4yWtrey3UJm5nP1kUbnYgLopqWNSRhWw=="
},
"sdp-transform": {
"version": "2.13.0",
"resolved": "https://registry.npmjs.org/sdp-transform/-/sdp-transform-2.13.0.tgz",
"integrity": "sha512-3zT7pcjR090E0WCV9eOtFX06iojoNKsyMXqXs7clOs8sy+RoegR0cebmCuCrTKdY2jw1XhT9jkraygJrqAUwzA=="
},
"semver": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-6.3.0.tgz",
@@ -4887,6 +4946,20 @@
"signal-exit": "^3.0.2"
}
},
"xml2js": {
"version": "0.4.23",
"resolved": "https://registry.npmjs.org/xml2js/-/xml2js-0.4.23.tgz",
"integrity": "sha512-ySPiMjM0+pLDftHgXY4By0uswI3SPKLDw/i3UXbnO8M/p28zqexCUoPmQFrYD+/1BzhGJSs2i1ERWKJAtiLrug==",
"requires": {
"sax": ">=0.6.0",
"xmlbuilder": "~11.0.0"
}
},
"xmlbuilder": {
"version": "11.0.1",
"resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-11.0.1.tgz",
"integrity": "sha512-fDlsI/kFEx7gLvbecc0/ohLG50fugQp8ryHzMTuW9vSa1GJ0XYWKnhsUx7oie3G98+r56aTQIUB4kht42R3JvA=="
},
"xtend": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz",

View File

@@ -28,9 +28,9 @@
"config": "^3.2.4",
"debug": "^4.1.1",
"drachtio-fn-b2b-sugar": "0.0.12",
"drachtio-fsmrf": "1.5.10",
"drachtio-fsmrf": "^1.5.11",
"drachtio-srf": "^4.4.27",
"jambonz-db-helpers": "^0.1.6",
"jambonz-db-helpers": "^0.1.7",
"moment": "^2.24.0",
"pino": "^5.14.0",
"request": "^2.88.0",

View File

@@ -2,4 +2,4 @@
"sip:decline": {
"status": "hello"
}
}
}

View File

@@ -0,0 +1,31 @@
[
{
"verb": "gather",
"action": "https://00dd977a.ngrok.io/gather",
"input": ["speech"],
"timeout": 12,
"recognizer": {
"vendor": "google",
"language": "en-US",
"hints": ["sales", "support", "engineering", "human resources", "HR", "operator", "agent"]
},
"say": {
"text": "Please say the name of the department that you would like to speak with. To speak to an operator, just say operator.",
"synthesizer": {
"vendor": "google",
"voice": "en-US-Wavenet-C"
}
}
},
{
"verb": "say",
"text": "I'm sorry, I did not hear a response. Goodbye.",
"synthesizer": {
"vendor": "google",
"voice": "en-US-Wavenet-C"
}
},
{
"verb": "hangup"
}
]

View File

@@ -9,11 +9,10 @@
}
],
"listen": {
"wsUrl": "wss://myrecorder.example.com:4433",
"url": "wss://myrecorder.example.com:4433",
"mixType" : "stereo",
"sampleRate": 8000,
"passDtmf": true,
"source": "parent",
"metadata": {
"clientId": "12udih"
}

View File

@@ -13,4 +13,4 @@
}
]
}
}
}

View File

@@ -10,11 +10,12 @@
],
"transcribe": {
"action": "http://example.com/transcribe",
"language" : "en-US",
"source" : "both",
"interim": true,
"vendor": "google",
"jsonKey": "--json service key--"
"recognizer": {
"vendor": "google",
"language" : "en-US",
"mixType" : "stereo",
"interim": true
}
}
}
}

View File

@@ -36,6 +36,13 @@ test('app payload parsing tests', (t) => {
task = makeTask(logger, require('./data/good/dial-listen'));
t.ok(task.name === 'dial', 'parsed dial w/ listen');
const alt = require('./data/good/alternate-syntax');
const normalize = require('../lib/utils/normalize-jamones');
normalize(logger, alt).forEach((t) => {
const task = makeTask(logger, t);
});
t.pass('alternate syntax works');
t.end();
});