Skip to content

Instantly share code, notes, and snippets.

@Miha-x64
Last active April 10, 2023 09:29
Show Gist options
  • Star 0 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save Miha-x64/d068f8ae0cc7b0123eff1b466578adba to your computer and use it in GitHub Desktop.
Save Miha-x64/d068f8ae0cc7b0123eff1b466578adba to your computer and use it in GitHub Desktop.
async-await for VoxImplant VoxEngine

This library provides several awaitable commands to VoxImplant VoxEngine API.

This is unnofficial community-driven software licensed under Apache 2.0 and provided “as is”, without warranty of any kind.

Idea

There's a plenty of event listeners and async callbacks, as always in JS. The idea is to provide a handy way of managing async operations and awaiting their results.

For example, you can play a sound or text-to-speech while calling a call center or waiting for a DTMF tone. When something happens, you must handle the result:

  • if tone received, move on to the requested subroutine,
  • if call center answers, just sendMediaBetween and let them talk,
  • if the sound or text-to speech has ended but nothing happens, maybe say something else, call a fallback call center, or reject the call.

Under the scenes

JS is used for asynchronous processing for years, it has async-await, but damn, native Promise is not cancelable. If you have a long subroutine–for example, play sound, say some text, wait for several seconds, then repeat–it's necessary to cancel it when the call is answered or tone is received. Otherwise, a running subroutine will start to play something odd, replacing current audio stream and screwing up the whole scenario.

Thus, the library provides a Future which is a Promise with such methods as cancel(), fMap() (a cancellation-aware then), and more.

There are also some utils borrowed from Promise like Future.any(...futures) which awaits the first result and cancels the others.

Well, nice, we can start awaiting? Not yet! The Promise is built into the language and runtime: every time when a function reaches await promise, it immediately returns something like promise.then(restOfTryBlock, catchBlock).then(restOfFunction). Now we've lost our control over a subroutine, it is not cancelable anymore, so it's time to control this by hand. Here's a part of example.ts illustrating this:

             
VoxEngine.addEventListener(AppEvents.CallAlerting, async ev => {
    const call = ev.call;
    call.startEarlyMedia();
    call.answer();

    // To regain the control over the cancellation and its propagation,
    // we create a [disposeBag, disposeHandle] tuple.
    const [bag, dispose] = Future.newBag();
    
    // If the caller is disappeared, stop everything, and jump from try{}
    call.when(CallEvents.Disconnected).then(dispose); // to catch{} and finally{} immediately.

    try {
        
        while (true) {
            const sayingAndWaiting = call.speak(, VOICE).also(() => Future.delay(3000));
         
            if (firstTime) {
                sayingAndWaiting.with(bag);
                await call.when(CallEvents.Connected).with(bag);
                // Just put every running Future into the bag before awaiting anything.
            }
            
            // We're saying and listening…
            const proceed = await handleResult(
                call, bag,
                await Future.any<CallPlaybackFinishedEvent | CallToneReceivedEvent>(
                    sayingAndWaiting, call.receiveTone(true, true, TONES),
                ).with(bag), // …but will stop if canceled.
            );            
            // If not canceled, then either is complete so it's time to decide.
            if (!proceed) ;

            
        }
    } catch (e) {
        if (!(e instanceof CanceledError))
            throw e; // something really bad have happened
    } finally {
        VoxEngine.terminate();
    }
});

Idioms

Hello! Press 1 or 2.

const playing = call.playSound(HELLO)
    .fMap(() => this.call.playSound(PRESS))
    .catching(CanceledError) // avoid ‘unhandled promise rejection’ on cancel
    .with(bag);
const [, { tone }] = await call.receiveTone(true, true, ['1', '2']).with(bag);
playing.cancel(); // don't play PRESS if tone received during HELLO
handleTone(tone);

Record a call and send a link.

call.capture().then(([, ev]) => slackMonitoring.sendCallRecLink(ev.url));

Call several numbers. When one is answered, drop others. If all failed, unwrap error.

Future.any(...phones.map(phone =>
    VoxEngine.call…().connect()
)).catching(Array, ([first,]) => { throw first; });

Fall back if call failed.

const ev = await VoxEngine.call…().connect().catching(CallFailedError).with(bag);
if (ev) {
    // ev: [ CallEvents.Connected, _CallConnectedEvent ]
} else {
    // ev: void, CallFailedError is caught, try something else
}

Usage in a local IDE

Download Vox typings and fix compilation errors:

wget https://cdn.voximplant.com/voxengine_typings/voxengine.d.ts
wget -O- https://gist.githubusercontent.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba/raw/voxengine.d.ts.diff | patch

And then download this library:

mkdir async_vox && cd async_vox
wget https://gist.githubusercontent.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba/raw/async_vox.ts
wget https://gist.githubusercontent.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba/raw/tsconfig.json

Back to your project (cd ..), don't forget to .gitignore:

# https://cdn.voximplant.com/voxengine_typings/voxengine.d.ts
voxengine.d.ts

# https://gist.github.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba
async_vox/

Usage inside VoxImplant

Create a script async_vox, copy-paste async_vox.js file contents, and add it to routing before your scripts which use it. You can deploy example.js to see how it works. Then, script list in your routing rule would look like this:

  • async_vox
  • example
/*
Unofficial Async-Vox library by Mike Gorünóv,
a sole developer not affiliated with Voximplant®.
https://gist.github.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
const Future = {
delay: function (millis) {
const future = _avf_new();
const resolve = future.resolve;
const id = setTimeout(resolve, millis);
const cancel = future.cancel;
future.cancel = () => {
clearTimeout(id);
cancel();
};
return future;
},
any: function (...futures) {
return _avf_merge(futures, _avf_Promise_any, true);
},
all: function (...futures) {
return _avf_merge(futures, Promise.all, false);
},
race: function (...futures) {
return _avf_merge(futures, Promise.race, true);
},
newBag: function () {
const bag = [];
return [bag, _avf_cancelFuturesFunc(bag)];
},
};
class CanceledError extends Error {
constructor(cancellationSignal) {
super('' + cancellationSignal);
this.cancellationSignal = cancellationSignal;
}
}
class CallError extends Error {
constructor(event, message) {
super(message);
this.event = event;
}
}
class CallFailedError extends CallError {
constructor(event) {
super(event, `${event.code}: ${event.reason}`);
}
}
class CallRecordError extends CallError {
constructor(event) {
super(event, event.error);
}
}
const _AV_DEBUG = false;
let _avf_futureCount = 0;
function _avf_isFuture(maybeFuture) {
return maybeFuture instanceof Promise &&
typeof maybeFuture.cancel === 'function';
}
const _avf_just = value => () => value;
const _avf_undefined = _avf_just(undefined);
const _avf_true = _avf_just(true);
const _avf_false = _avf_just(false);
function _avf_withFunctions(future, debug) {
future.fMap = (mapRes, mapRej) => {
let mappedFuture;
const combined = future.then(value => {
let ret = value;
if (mapRes && _avf_isFuture(ret = mapRes(value)))
mappedFuture = ret;
return ret;
}, reason => {
let err = reason;
if (mapRej) {
if (_avf_isFuture(err = mapRej(reason)))
mappedFuture = err;
return err;
}
throw err;
});
combined.cancel = () => {
future.cancel();
if (mappedFuture)
mappedFuture.cancel();
};
return _avf_withFunctions(combined, !_AV_DEBUG ? undefined
: ` = ${future}.fMap(${mapRes.name || '<anonymous>'}, ${mapRej ? (mapRej.name || '<anonymous>') : undefined})`);
};
future.also = (source) => {
let value;
return future.fMap(v => source(value = v)).fMap(() => value);
};
future.catching = (type, mapRej) => future
.fMap(_avf_identity, e => { if (e instanceof type)
return mapRej ? mapRej(e) : undefined;
else
throw e; });
future.state = () => {
future.state = _avf_undefined;
future.then(() => future.state = _avf_true, () => future.state = _avf_false);
return future.state();
};
future.with = ((bag) => {
let i = bag.length;
while (i--) {
const f = bag[i];
if (f.state() !== undefined)
bag.splice(i, 1);
if (f === future)
return future;
}
if (future.state() === undefined)
bag.push(future);
return future;
});
if (_AV_DEBUG) {
const id = ++_avf_futureCount;
future.toString = () => `Future#${id}`;
Logger.write(`create ${future}${debug} at ${Error().stack || "<no stack trace available>"}`);
let canceled = false;
future.then(res => Logger.write(`${future} resolved with ${res}`), err => Logger.write(`${future} ${canceled ? 'canceled' : 'rejected'} with ${(err === null || err === void 0 ? void 0 : err.NAME) || (err === null || err === void 0 ? void 0 : err.name) || err}`));
const cancel = future.cancel;
future.cancel = () => {
canceled = true;
cancel();
};
}
return future;
}
function _avf_new(cancellationSignal) {
let resolve, reject;
const future = new Promise((res, rej) => { resolve = res; reject = rej; });
future.resolve = resolve;
future.reject = reject;
future.cancel = () => reject(new CanceledError(cancellationSignal));
return _avf_withFunctions(future, _AV_DEBUG ? `, cancellationSignal=${cancellationSignal}` : undefined);
}
const _avf_cancelFuture = (future) => future.cancel();
const _avf_cancelFuturesFunc = (futures) => () => futures.forEach(_avf_cancelFuture);
function _avf_merge(futures, combine, thenCancelSrc) {
const cancel = _avf_cancelFuturesFunc(futures);
const future = combine.call(Promise, futures);
if (thenCancelSrc)
_avf_finally(future, cancel);
future.cancel = cancel;
return _avf_withFunctions(future, !_AV_DEBUG ? undefined
: ` = Promise.${combine.name}(${futures.join(', ')})`);
}
const _avf_identity = obj => obj;
const _avf_Promise_reject = e => Promise.reject(e);
function _avf_Promise_any(promises) {
return Promise
.all(promises.map(promise => promise.then(_avf_Promise_reject, _avf_identity)))
.then(_avf_Promise_reject, _avf_identity);
}
function _avf_finally(future, cleanup) {
future.then(cleanup, cleanup);
return future;
}
function _av_c2f(call, future, event, callback) {
call.addEventListener(event, callback);
return _avf_finally(future, () => call.removeEventListener(event, callback));
}
Call.prototype.when = function (event) {
const future = _avf_new(event.NAME || event);
return _av_c2f(this, future, event, (e) => future.resolve([event, e]));
};
Call.prototype.connect = function () {
const future = this.when(CallEvents.Connected);
return _av_c2f(this, future, CallEvents.Failed, (e) => future.reject(new CallFailedError(e))).catching(CanceledError, e => { this.hangup(); throw e; });
};
const _AV_ALL_TONES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'];
Call.prototype.receiveTone = function (enable = true, disable = true, allow = _AV_ALL_TONES) {
if (enable)
this.handleTones(true);
const future = this.when(CallEvents.ToneReceived)
.fMap((ev) => allow.includes(ev[1].tone) ? ev : this.receiveTone(false, false, allow));
return disable ? _avf_finally(future, () => this.handleTones(false)) : future;
};
Call.prototype.capture = function (params) {
this.record(params);
const future = this.when(CallEvents.RecordStarted);
return _av_c2f(this, future, CallEvents.RecordError, (e) => future.reject(new CallRecordError(e)));
};
Call.prototype.playSound = function (url, durationMillis, progressive = true) {
this.startPlayback(url, { loop: durationMillis !== undefined, progressivePlayback: progressive });
return durationMillis !== undefined
? this.when(CallEvents.PlaybackStarted).also(() => Future.delay(durationMillis))
: this.when(CallEvents.PlaybackFinished);
};
Call.prototype.speak = function (text, voice, options) {
this.say(text, { language: voice, progressivePlayback: true, ttsOptions: options });
return this.when(CallEvents.PlaybackFinished);
};
/*
Unofficial Async-Vox library by Mike Gorünóv,
a sole developer not affiliated with Voximplant®.
https://gist.github.com/Miha-x64/d068f8ae0cc7b0123eff1b466578adba
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import '../voxengine';
/**
* A cancelable Promise.
*/
export interface Future<T> extends PromiseLike<T> {
/**
* Cancels this Future.
* If in progress, it will be immediately rejected with {@link CanceledError}.
*/
cancel(): void;
/**
* Enqueues a computation after this Future resolution.
* Canceling the returned Future will lead to cancellation of the source Future, too.
* @param mapRes a function called after resolution
* @param mapRej a function called after rejection (including cancellation)
* @return Future with return type of mapper
*/
fMap<R1 = T, R2 = never>(
mapRes?: (value: T) => PromiseLike<R1> | R1,
mapRej?: (reason: any) => R2 | PromiseLike<R2>,
): Future<R1 | R2>;
/**
* Enqueues a side-effect and ignores its return type.
* @param source a function called after resolution
*/
also(source: (value: T) => any): Future<T>
/**
* Adds an error handler.
* @param type error type
* @param mapRej mapper function
*/
catching<E, R2 = void>(
type: { new(...args: any[]): E }, mapRej?: (reason: E) => R2 | PromiseLike<R2>
): Future<T | R2>
/**
* Current state of this Future.
* true: fulfilled
* false: rejected
* undefined: in progress
*/
state(): boolean | undefined
/**
* Adds this Future to a bag, so its owner could cancel it.
*/
with(bag: DisposeBag): this
}
/**
* Contains a number of Futures intended for cancellation propagation.
*/
export interface DisposeBag {}
export type CallConnectedEvent = [CallEvents.Connected, _ConnectedEvent];
export type CallPlaybackStartedEvent = [CallEvents.PlaybackStarted, _PlaybackStartedEvent];
export type CallPlaybackFinishedEvent = [CallEvents.PlaybackFinished, _PlaybackFinishedEvent];
export type CallToneReceivedEvent = [CallEvents.ToneReceived, _ToneReceivedEvent];
export type CallRecordStarted = [CallEvents.RecordStarted, _RecordStartedEvent]
export type CallEvent = CallConnectedEvent
| CallPlaybackStartedEvent
| CallPlaybackFinishedEvent
| CallToneReceivedEvent
| CallRecordStarted;
export type CallPlaybackEvent = CallPlaybackStartedEvent | CallPlaybackFinishedEvent
declare global {
/*extension*/ interface Call {
// input
/**
* Create a Future that completes when the requested event happens.
* The result is a tuple of event type and value.
* @see CallEvent
*/
when<EV extends keyof _CallEvents>(event: EV): Future<[EV, _CallEvents[EV]]>;
/**
* Create a Future which
* gets fulfilled if this call is {@link CallEvents.Connected | connected},
* gets rejected with {@link CallFailedError} if the call is {@link CallEvents.Failed | failed},
* {@link Call.hangup | hangs the call up} if canceled.
*/
connect(): Future<CallConnectedEvent>
/**
* Creates a Future which gets resolved when {@link CallEvents.ToneReceived | a DTMF tone is received}.
* @param enable enable tone handling (pass false if handling tones is already enabled)
* @param disable disable tone handling when received (pass false to leave it enabled)
* @param allow a set of allowed tones (unlisted tones will be ignored)
*/
receiveTone(enable: boolean, disable: boolean, allow: readonly string[]): Future<CallToneReceivedEvent>
/**
* Starts recording this call. The returned Future
* gets fulfilled if the {@link CallEvents.RecordStarted | record is started},
* gets rejected if {@link CallEvents.RecordError | failed} before starting,
* does nothing if canceled.
* @param params VoxEngine recorder params
*/
capture(params?: VoxEngine.RecorderParameters): Future<CallRecordStarted>
// TODO detect voicemail, transfer, detect tone?..
// output
/**
* Plays sound and creates a Future which completes
* — after the requested duration, if requested;
* — after {@link CallEvents.PlaybackFinished | playback finished} event otherwise.
*/
playSound(url: string, durationMillis?: number, progressive?: boolean): Future<CallPlaybackEvent>;
/**
* Says something to a call and creates a Future which completes
* after {@link CallEvents.PlaybackFinished | playback finished} event.
*/
speak(text: string, voice: VoiceList.Voice, options?: VoxEngine.TTSOptions): Future<CallPlaybackFinishedEvent>
}
}
// TODO WS: receive message, maybe?
export const Future = {
/**
* Creates a Future which completes after the requested timeout.
*/
delay: function (millis: number): Future<undefined> {
const future = _avf_new<undefined>();
// @ts-ignore
const resolve = future.resolve as (value: undefined) => void;
const id = setTimeout(resolve, millis);
const cancel = future.cancel;
future.cancel = () => {
clearTimeout(id);
cancel();
};
return future;
},
/**
* Creates a Future that resolves when any of passed Futures is resolved,
* or rejected with error array when all of them are rejected.
*/
any: function <T>(...futures: readonly Future<T>[]): Future<T> {
return _avf_merge<T, T>(futures, _avf_Promise_any, true);
},
/**
* Creates a Future that resolves when all of passed Futures are resolved,
* or rejected if any of passed Futures are rejected.
*/
all: function <T>(...futures: readonly Future<T>[]): Future<T[]> {
return _avf_merge<T, T[]>(futures, Promise.all, false);
},
/**
* Creates a Future that completes when any of passed Futures is completed.
*/
race: function <T>(...futures: readonly Future<T>[]): Future<T> {
return _avf_merge<T, T>(futures, Promise.race, true);
},
/**
* Creates a DisposeBag to pass around and a dispose function.
*/
newBag: function (): [DisposeBag, () => void] {
// noinspection JSMismatchedCollectionQueryUpdate: wat? I return it!
const bag: Future<unknown>[] = [];
return [bag as DisposeBag, _avf_cancelFuturesFunc(bag)];
},
};
export class CanceledError extends Error {
cancellationSignal?: any
constructor(cancellationSignal?: any) {
super('' + cancellationSignal);
this.cancellationSignal = cancellationSignal;
}
}
export class CallError<EV extends _CallEvent> extends Error {
event: EV
constructor(event: EV, message: string) {
super(message);
this.event = event;
}
}
export class CallFailedError extends CallError<_FailedEvent> {
constructor(event: _FailedEvent) {
super(event, `${event.code}: ${event.reason}`);
}
}
export class CallRecordError extends CallError<_RecordErrorEvent> {
constructor(event: _RecordErrorEvent) {
super(event, event.error);
}
}
//======================================================================================================================
const _AV_DEBUG = false;
let _avf_futureCount = 0;
function _avf_isFuture<T>(maybeFuture: PromiseLike<T> | T): maybeFuture is Future<T> {
return maybeFuture instanceof Promise &&
// @ts-ignore
typeof maybeFuture.cancel === 'function';
}
const _avf_just = value => () => value;
const _avf_undefined = _avf_just(undefined);
const _avf_true = _avf_just(true);
const _avf_false = _avf_just(false);
function _avf_withFunctions<T>(future: Future<T>, debug?: string): Future<T> {
future.fMap = <R1 = T, R2 = never>(
mapRes?: (value: T) => PromiseLike<R1> | R1,
mapRej?: (reason: any) => R2 | PromiseLike<R2>,
) => {
let mappedFuture: Future<R1 | R2> | undefined;
// @ts-ignore
const combined: Future<R1 | R2> =
future.then(value => {
let ret: any = value;
if (mapRes && _avf_isFuture(ret = mapRes(value))) mappedFuture = ret;
return ret;
}, reason => {
let err: any = reason;
if (mapRej) {
if (_avf_isFuture(err = mapRej(reason))) mappedFuture = err;
return err;
}
throw err;
});
combined.cancel = () => {
future.cancel()
if (mappedFuture) mappedFuture.cancel();
};
return _avf_withFunctions(combined, !_AV_DEBUG ? undefined
// @ts-ignore
: ` = ${future}.fMap(${mapRes.name || '<anonymous>'}, ${mapRej ? (mapRej.name || '<anonymous>') : undefined})`);
};
future.also = (source: (value: T) => any) => {
let value;
return future.fMap(v => source(value = v)).fMap(() => value);
};
future.catching = <E, R2 = void>(type: { new(...args: any[]): E }, mapRej?: (reason: E) => R2 | PromiseLike<R2>) => future
.fMap(_avf_identity, e => { if (e instanceof type) return mapRej ? mapRej(e) : undefined; else throw e; });
future.state = () => {
future.state = _avf_undefined;
future.then(() => future.state = _avf_true, () => future.state = _avf_false);
return future.state();
};
future.with = ((bag: Future<unknown>[]) => {
// removeIf borrowed from https://stackoverflow.com/a/15996017/3050249
let i = bag.length;
while (i--) {
const f = bag[i];
if (f.state() !== undefined)
bag.splice(i, 1);
if (f === future)
return future;
}
if (future.state() === undefined)
bag.push(future);
return future;
}) as (DisposeBag) => Future<T>;
if (_AV_DEBUG) {
const id = ++_avf_futureCount;
future.toString = () => `Future#${id}`;
Logger.write(`create ${future}${debug} at ${Error().stack || "<no stack trace available>"}`);
let canceled = false;
future.then(
res => Logger.write(
`${future} resolved with ${res}`
),
err => Logger.write(
`${future} ${canceled ? 'canceled' : 'rejected'} with ${err?.NAME || err?.name || err}`
),
);
const cancel = future.cancel;
future.cancel = () => {
canceled = true;
cancel();
};
}
return future;
}
function _avf_new<T>(cancellationSignal?: any): Future<T> {
let resolve: (value: T) => void, reject: (reason?: any) => void;
// @ts-ignore
const future: Future<T> =
new Promise<T>((res, rej) => { resolve = res; reject = rej; });
// @ts-ignore
future.resolve = resolve; future.reject = reject; // for private use only
future.cancel = () => reject(new CanceledError(cancellationSignal));
return _avf_withFunctions(future, _AV_DEBUG ? `, cancellationSignal=${cancellationSignal}` : undefined);
}
const _avf_cancelFuture = (future: Future<unknown>) => future.cancel();
const _avf_cancelFuturesFunc = (futures: readonly Future<unknown>[]) => () => futures.forEach(_avf_cancelFuture);
function _avf_merge<T, U>(
futures: readonly Future<T>[],
combine: (promises: readonly PromiseLike<T>[]) => Promise<U>,
thenCancelSrc: boolean,
): Future<U> {
const cancel = _avf_cancelFuturesFunc(futures);
// @ts-ignore
const future: Future<U> = combine.call(Promise, futures); // We pass detached member functions of Promise here
if (thenCancelSrc) _avf_finally(future, cancel);
future.cancel = cancel; // ^^^^- cancel if fulfilled (useful both for `race` and `any`) and if rejected (for `race`)
return _avf_withFunctions(future, !_AV_DEBUG ? undefined
// @ts-ignore
: ` = Promise.${combine.name}(${futures.join(', ')})`);
}
// borrowed from https://dev.to/sinxwal/looking-for-promise-any-let-s-quickly-implement-a-polyfill-for-it-1kga
// and https://github.com/es-shims/Promise.any/blob/master/implementation.js
const _avf_identity = obj => obj;
const _avf_Promise_reject = e => Promise.reject(e);
function _avf_Promise_any<T>(promises: readonly PromiseLike<T>[]): Promise<T> {
return Promise
.all(promises.map(promise => promise.then(_avf_Promise_reject, _avf_identity)))
.then(_avf_Promise_reject, _avf_identity);
}
function _avf_finally<R>(future: Future<R>, cleanup: () => void): Future<R> {
future.then(cleanup, cleanup);
return future;
}
function _av_c2f<EV extends keyof _CallEvents, R>(
call: Call, future: Future<R>, event: EV, callback: (e: _CallEvents[EV]) => void,
): typeof future {
call.addEventListener(event, callback);
return _avf_finally(future, () => call.removeEventListener(event, callback));
}
Call.prototype.when = function <EV extends keyof _CallEvents>(event: EV): Future<[EV, _CallEvents[EV]]> {
const future = _avf_new<[EV, _CallEvents[EV]]>(
// @ts-ignore | events are functions with a NAME property
event.NAME || event);
return _av_c2f<EV, [EV, _CallEvents[EV]]>(
this, future, event,
(e: _CallEvents[EV]) => // @ts-ignore
(future.resolve as (value: [EV, _CallEvents[EV]]) => void)
([event, e]),
);
};
Call.prototype.connect = function () {
const future = this.when(CallEvents.Connected);
return _av_c2f<CallEvents.Failed, CallConnectedEvent>(
this, future, CallEvents.Failed,
(e: _FailedEvent) =>
// @ts-ignore
(future.reject as (a: any) => void)
(new CallFailedError(e))
).catching(CanceledError, e => { this.hangup(); throw e; });
}
const _AV_ALL_TONES = ['0', '1', '2', '3', '4', '5', '6', '7', '8', '9', '*', '#'];
Call.prototype.receiveTone = function (
enable: boolean = true,
disable: boolean = true,
allow: readonly string[] = _AV_ALL_TONES,
): Future<CallToneReceivedEvent> {
if (enable) this.handleTones(true);
const future = this.when(CallEvents.ToneReceived)
.fMap((ev: CallToneReceivedEvent) => allow.includes(ev[1].tone) ? ev : this.receiveTone(false, false, allow));
return disable ? _avf_finally(future, () => this.handleTones(false)) : future;
};
Call.prototype.capture = function (params?: VoxEngine.RecorderParameters): Future<CallRecordStarted> {
// @ts-ignore: params are actually optional, trust me
this.record(params);
const future = this.when(CallEvents.RecordStarted);
return _av_c2f<CallEvents.RecordError, CallRecordStarted>(
this, future, CallEvents.RecordError,
(e: _RecordErrorEvent) => // @ts-ignore
(future.reject as (a: any) => void)
(new CallRecordError(e))
);
}
Call.prototype.playSound = function (
url: string, durationMillis?: number, progressive: boolean = true,
): Future<CallPlaybackEvent> {
this.startPlayback(url, {loop: durationMillis !== undefined, progressivePlayback: progressive});
return durationMillis !== undefined
? this.when(CallEvents.PlaybackStarted).also(() => Future.delay(durationMillis))
: this.when(CallEvents.PlaybackFinished);
};
Call.prototype.speak = function (
text: string, voice: VoiceList.Voice, options?: VoxEngine.TTSOptions
): Future<CallPlaybackFinishedEvent> {
this.say(text, {language: voice, progressivePlayback: true, ttsOptions: options});
return this.when(CallEvents.PlaybackFinished);
};
// import '../voxengine';
// import { CanceledError, Future } from "./async_vox";
const SOUNDS = {
blackbird: 'http://www.wildsong.co.uk/sounds/sounds_blackbird/blackbird_felton_p01.mp3',
flycatcher: 'http://www.birding.dk/galleri/stemmermp3/Ficedula%20hypoleuca%201.mp3',
grebe: 'http://www.vogelstimmen.info/Vogelstimmen_GRATIS/Bindentaucher_Podilymbus_podiceps_R_AMPLE-E0105.mp3',
jay: 'http://northwestbirding.com/BirdSongs/Recordings/blue_jays_silver_lake_ohio_10-15-12.mp3',
meadowlark: 'http://northwestbirding.com/BirdSongs/Recordings/western_meadowlark_wasco_county_06-05-13.mp3',
};
const SOUND_LINKS = Object.values(SOUNDS);
const TONES = Object.keys(SOUND_LINKS.concat(''));
const VOICE = VoiceList.Default.en_US_Female;
VoxEngine.addEventListener(AppEvents.CallAlerting, async (ev) => {
const call = ev.call;
call.startEarlyMedia();
call.answer();
const [bag, dispose] = Future.newBag();
call.when(CallEvents.Disconnected).then(dispose);
try {
let firstTime = true;
while (true) {
const prompt = !firstTime ? "can I help you any more?" :
Object.keys(SOUNDS).reduceRight((prev, cur, idx) => `${idx + 1} — ${cur}, ${prev}`, '0 — farewell');
const sayingAndWaiting = call.speak(prompt, VOICE).also(() => Future.delay(3000));
if (firstTime) {
sayingAndWaiting.with(bag);
await call.when(CallEvents.Connected).with(bag);
}
const proceed = await handleResult(call, bag, await Future.any(sayingAndWaiting, call.receiveTone(true, true, TONES)).with(bag));
if (!proceed)
break;
firstTime = false;
}
}
catch (e) {
if (!(e instanceof CanceledError))
throw e;
}
finally {
VoxEngine.terminate();
}
});
async function handleResult(call, bag, toneOrSaid) {
switch (toneOrSaid[0]) {
case CallEvents.PlaybackFinished:
await call.speak("you've been thinking for too long", VOICE).with(bag);
return false;
case CallEvents.ToneReceived:
const idx = parseInt(toneOrSaid[1].tone) - 1;
if (SOUND_LINKS[idx]) {
await call.playSound(SOUND_LINKS[idx]).with(bag);
return true;
}
else {
await call.speak("goodbye", VOICE).with(bag);
return false;
}
}
throw Error(`Unexpected event: ${toneOrSaid[0]['NAME']}`);
}
import '../voxengine';
import {
CallEvent,
CallPlaybackFinishedEvent,
CallToneReceivedEvent,
CanceledError,
DisposeBag,
Future
} from "./async_vox";
// noinspection HttpUrlsUsage
const SOUNDS: Readonly<Record<string, string>> = {
blackbird: 'http://www.wildsong.co.uk/sounds/sounds_blackbird/blackbird_felton_p01.mp3',
flycatcher: 'http://www.birding.dk/galleri/stemmermp3/Ficedula%20hypoleuca%201.mp3',
grebe: 'http://www.vogelstimmen.info/Vogelstimmen_GRATIS/Bindentaucher_Podilymbus_podiceps_R_AMPLE-E0105.mp3',
jay: 'http://northwestbirding.com/BirdSongs/Recordings/blue_jays_silver_lake_ohio_10-15-12.mp3',
meadowlark: 'http://northwestbirding.com/BirdSongs/Recordings/western_meadowlark_wasco_county_06-05-13.mp3',
};
const SOUND_LINKS: readonly string[] = Object.values(SOUNDS)
// 5 sounds — tones 1, 2, 3, 4, 5 and 0 for hangup are expected
const TONES: readonly string[] = Object.keys(SOUND_LINKS.concat(''))
const VOICE = VoiceList.Default.en_US_Female;
VoxEngine.addEventListener(AppEvents.CallAlerting, async ev => {
const call = ev.call;
call.startEarlyMedia();
call.answer();
const [bag, dispose] = Future.newBag();
call.when(CallEvents.Disconnected).then(dispose);
try {
let firstTime = true;
while (true) {
const prompt = !firstTime ? "can I help you any more?" :
Object.keys(SOUNDS).reduceRight((prev, cur, idx) => `${idx + 1} — ${cur}, ${prev}`, '0 — farewell');
const sayingAndWaiting = call.speak(prompt, VOICE).also(() => Future.delay(3000));
if (firstTime) {
sayingAndWaiting.with(bag);
await call.when(CallEvents.Connected).with(bag);
}
const proceed = await handleResult(
call, bag,
await Future.any<CallPlaybackFinishedEvent | CallToneReceivedEvent>(
sayingAndWaiting, call.receiveTone(true, true, TONES),
).with(bag),
);
if (!proceed) break;
firstTime = false;
}
} catch (e) {
if (!(e instanceof CanceledError))
throw e;
} finally {
VoxEngine.terminate();
}
});
/**
*
* @return boolean whether we should proceed
*/
async function handleResult(call: Call, bag: DisposeBag, toneOrSaid: CallEvent): Promise<boolean> {
switch (toneOrSaid[0]) {
case CallEvents.PlaybackFinished:
await call.speak("you've been thinking for too long", VOICE).with(bag);
return false;
case CallEvents.ToneReceived:
const idx = parseInt(toneOrSaid[1].tone) - 1;
if (SOUND_LINKS[idx]) {
await call.playSound(SOUND_LINKS[idx]).with(bag);
return true;
} else {
await call.speak("goodbye", VOICE).with(bag);
return false;
}
}
throw Error(`Unexpected event: ${toneOrSaid[0]['NAME']}`);
}
{
"compilerOptions": {
"target": "ES2018",
"lib": ["ES2018"],
"removeComments": true, // rm @ts-ignore
"strict": true,
"noImplicitAny": false, // required for Vox typings
},
"files": ["async_vox.ts", "example.ts"]
}
--- voxengine.d.ts 2021-09-17 21:48:56.051772049 +0300
+++ voxengine.d.ts 2021-09-17 22:00:11.981868986 +0300
@@ -836,7 +836,7 @@
public sendMediaTo(targetMediaUnit: VoxMediaUnit, optional?: sendMediaOptions): void;
/**
- * Stop sending voice from a Dialogflow participant to the media unit specified in targetCall.​
+ * Stop sending voice from a Dialogflow participant to the media unit specified in targetCall.
*/
public stopMediaTo(targetMediaUnit: VoxMediaUnit): void;
@@ -2047,6 +2047,7 @@
/**
* Profile that specifies an ASR provider and a language to use.
*/
+ // @ts-ignore
profile: ASRProfileList;
/**
* Enables/disables interim ASR results. If it is "true", the [ASREvents.InterimResult] will be triggered many times according to the speech.
@@ -2066,6 +2067,7 @@
/**
* Recognition model. Select the model best suited to your domain to get the best results. If it's not specified, the **DEFAULT** model is used.
*/
+ // @ts-ignore
model?: ASRModelList;
/**
@@ -3653,7 +3655,9 @@
* @beta
*/
interface ReceiveParameters {
+ // @ts-ignore
all?: ParticipantReceiveParameters;
+ // @ts-ignore
new?: ParticipantReceiveParameters;
[remoteParticipantId: string]: ParticipantReceiveParameters;
}
@@ -4226,7 +4230,7 @@
call(): Call;
/**
- * Add a message from a participant into the CCAI Dialogflow.​
+ * Add a message from a participant into the CCAI Dialogflow.
*/
analyzeContent(query: CCAI.Vendor.EventInput | CCAI.Vendor.TextInput): void;
@@ -4649,7 +4653,7 @@
* Event is triggered by the [Call.startPlayback] and [Call.say] methods when audio/voice playback is started.
* @typedef _PlaybackStartedEvent
*/
- PlaybackStarted = 'Call.PlaybackStarted ',
+ PlaybackStarted = 'Call.PlaybackStarted',
/**
* Event is triggered when call statistic changed.
* @deprecated
@@ -8705,6 +8709,7 @@
/**
* Specifies the reason of activity's termination.
*/
+ // @ts-ignore
readonly terminationStatus: SmartQueueEndStatus | null;
/**
* The activity's Call object.
@@ -10341,7 +10346,7 @@
* @param callback Handler function. A single parameter is passed - object with event information
*/
addEventListener<T extends keyof _CallEvents>(
- event: CallEvents | T,
+ event: T,
callback: (ev: _CallEvents[T]) => any
): void;
@@ -10350,7 +10355,7 @@
* @param event One of the CallEvents (e.g. CallEvents.Connected)
* @param callback Handler function. If not specified, all event listeners are removed
*/
- removeEventListener(event: CallEvents, callback: (ev: any) => any): void;
+ removeEventListener<T extends keyof _CallEvents>(event: T, callback: (ev: _CallEvents[T]) => any): void;
clientType(): string;
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment