Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Web sdk #5

Merged
merged 16 commits into from
Jan 15, 2024
Merged
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
finish
albho committed Jan 9, 2024
commit 8cfda37eef077c196f35f3145749eedb896d24fd
13 changes: 7 additions & 6 deletions binding/web/cypress/support/commands.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@

const WAV_HEADER_SIZE = 44;

Cypress.Commands.add("getFramesFromFile", (path: string) => {
cy.fixture(path, 'base64').then(Cypress.Blob.base64StringToBlob).then(async blob => {
const data = new Int16Array(await blob.arrayBuffer());
return data.slice(WAV_HEADER_SIZE / Int16Array.BYTES_PER_ELEMENT);
});
Cypress.Commands.add('getFramesFromFile', (path: string) => {
cy.fixture(path, 'base64')
.then(Cypress.Blob.base64StringToBlob)
.then(async blob => {
const data = new Int16Array(await blob.arrayBuffer());
return data.slice(WAV_HEADER_SIZE / Int16Array.BYTES_PER_ELEMENT);
});
});
2 changes: 1 addition & 1 deletion binding/web/cypress/support/index.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import "./commands";
import './commands';

declare global {
namespace Cypress {
5 changes: 1 addition & 4 deletions binding/web/cypress/tsconfig.json
Original file line number Diff line number Diff line change
@@ -3,9 +3,6 @@
"compilerOptions": {
"types": ["cypress"]
},
"include": [
"../test/**/*.ts",
"./**/*.ts"
],
"include": ["../test/**/*.ts", "./**/*.ts"],
"exclude": []
}
2 changes: 1 addition & 1 deletion binding/web/module.d.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
declare module "*.wasm" {
declare module '*.wasm' {
const content: string;
export default content;
}
2 changes: 1 addition & 1 deletion binding/web/package.json
Original file line number Diff line number Diff line change
@@ -3,7 +3,7 @@
"description": "Falcon Speaker Diarization engine for web browsers (via WebAssembly)",
"author": "Picovoice Inc",
"license": "Apache-2.0",
"version": "2.0.1",
"version": "1.0.0",
"keywords": [
"falcon",
"web",
4 changes: 2 additions & 2 deletions binding/web/rollup.config.js
Original file line number Diff line number Diff line change
@@ -69,7 +69,7 @@ export default {
exclude: '**/node_modules/**',
}),
base64({
include: ['./lib/**/*.wasm']
})
include: ['./lib/**/*.wasm'],
}),
],
};
9 changes: 1 addition & 8 deletions binding/web/scripts/copy_wasm.js
Original file line number Diff line number Diff line change
@@ -5,14 +5,7 @@ const wasmFiles = ['pv_falcon.wasm', 'pv_falcon_simd.wasm'];

console.log('Copying the WASM model...');

const sourceDirectory = join(
__dirname,
'..',
'..',
'..',
'lib',
'wasm',
);
const sourceDirectory = join(__dirname, '..', '..', '..', 'lib', 'wasm');

const outputDirectory = join(__dirname, '..', 'lib');

20 changes: 10 additions & 10 deletions binding/web/scripts/setup_test.js
Original file line number Diff line number Diff line change
@@ -12,7 +12,7 @@ const paramsSourceDirectory = join(
'..',
'..',
'lib',
'common',
'common'
);

const testDataSource = join(
@@ -25,26 +25,26 @@ const testDataSource = join(
'test_data.json'
);

const sourceDirectory = join(
__dirname,
"..",
"..",
"..",
"resources",
);
const sourceDirectory = join(__dirname, '..', '..', '..', 'resources');

try {
fs.mkdirSync(testDirectory, { recursive: true });

fs.readdirSync(paramsSourceDirectory).forEach(file => {
fs.copyFileSync(join(paramsSourceDirectory, file), join(testDirectory, file));
fs.copyFileSync(
join(paramsSourceDirectory, file),
join(testDirectory, file)
);
});

fs.copyFileSync(testDataSource, join(testDirectory, 'test_data.json'));

fs.mkdirSync(join(fixturesDirectory, 'audio_samples'), { recursive: true });
fs.readdirSync(join(sourceDirectory, 'audio_samples')).forEach(file => {
fs.copyFileSync(join(sourceDirectory, 'audio_samples', file), join(fixturesDirectory, 'audio_samples', file));
fs.copyFileSync(
join(sourceDirectory, 'audio_samples', file),
join(fixturesDirectory, 'audio_samples', file)
);
});
} catch (error) {
console.error(error);
583 changes: 583 additions & 0 deletions binding/web/src/falcon.ts

Large diffs are not rendered by default.

256 changes: 256 additions & 0 deletions binding/web/src/falcon_errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,256 @@
//
// Copyright 2024 Picovoice Inc.
//
// You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE"
// file accompanying this source.
//
// 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 { PvError } from '@picovoice/web-utils';
import { PvStatus } from './types';

class FalconError extends Error {
private readonly _status: PvStatus;
private readonly _shortMessage: string;
private readonly _messageStack: string[];

constructor(
status: PvStatus,
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(FalconError.errorToString(message, messageStack, pvError));
this._status = status;
this.name = 'FalconError';
this._shortMessage = message;
this._messageStack = messageStack;
}

get status(): PvStatus {
return this._status;
}

get shortMessage(): string {
return this._shortMessage;
}

get messageStack(): string[] {
return this._messageStack;
}

private static errorToString(
initial: string,
messageStack: string[],
pvError: PvError | null = null
): string {
let msg = initial;

if (pvError) {
const pvErrorMessage = pvError.getErrorString();
if (pvErrorMessage.length > 0) {
msg += `\nDetails: ${pvErrorMessage}`;
}
}

if (messageStack.length > 0) {
msg += `: ${messageStack.reduce(
(acc, value, index) => acc + '\n [' + index + '] ' + value,
''
)}`;
}

return msg;
}
}

class FalconOutOfMemoryError extends FalconError {
constructor(
message: string,
messageStack?: string[],
pvError: PvError | null = null
) {
super(PvStatus.OUT_OF_MEMORY, message, messageStack, pvError);
this.name = 'FalconOutOfMemoryError';
}
}

class FalconIOError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.IO_ERROR, message, messageStack, pvError);
this.name = 'FalconIOError';
}
}

class FalconInvalidArgumentError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.INVALID_ARGUMENT, message, messageStack, pvError);
this.name = 'FalconInvalidArgumentError';
}
}

class FalconStopIterationError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.STOP_ITERATION, message, messageStack, pvError);
this.name = 'FalconStopIterationError';
}
}

class FalconKeyError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.KEY_ERROR, message, messageStack, pvError);
this.name = 'FalconKeyError';
}
}

class FalconInvalidStateError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.INVALID_STATE, message, messageStack, pvError);
this.name = 'FalconInvalidStateError';
}
}

class FalconRuntimeError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.RUNTIME_ERROR, message, messageStack, pvError);
this.name = 'FalconRuntimeError';
}
}

class FalconActivationError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.ACTIVATION_ERROR, message, messageStack, pvError);
this.name = 'FalconActivationError';
}
}

class FalconActivationLimitReachedError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.ACTIVATION_LIMIT_REACHED, message, messageStack, pvError);
this.name = 'FalconActivationLimitReachedError';
}
}

class FalconActivationThrottledError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.ACTIVATION_THROTTLED, message, messageStack, pvError);
this.name = 'FalconActivationThrottledError';
}
}

class FalconActivationRefusedError extends FalconError {
constructor(
message: string,
messageStack: string[] = [],
pvError: PvError | null = null
) {
super(PvStatus.ACTIVATION_REFUSED, message, messageStack, pvError);
this.name = 'FalconActivationRefusedError';
}
}

export {
FalconError,
FalconOutOfMemoryError,
FalconIOError,
FalconInvalidArgumentError,
FalconStopIterationError,
FalconKeyError,
FalconInvalidStateError,
FalconRuntimeError,
FalconActivationError,
FalconActivationLimitReachedError,
FalconActivationThrottledError,
FalconActivationRefusedError,
};

export function pvStatusToException(
pvStatus: PvStatus,
errorMessage: string,
messageStack: string[] = [],
pvError: PvError | null = null
): FalconError {
switch (pvStatus) {
case PvStatus.OUT_OF_MEMORY:
return new FalconOutOfMemoryError(errorMessage, messageStack, pvError);
case PvStatus.IO_ERROR:
return new FalconIOError(errorMessage, messageStack, pvError);
case PvStatus.INVALID_ARGUMENT:
return new FalconInvalidArgumentError(
errorMessage,
messageStack,
pvError
);
case PvStatus.STOP_ITERATION:
return new FalconStopIterationError(errorMessage, messageStack, pvError);
case PvStatus.KEY_ERROR:
return new FalconKeyError(errorMessage, messageStack, pvError);
case PvStatus.INVALID_STATE:
return new FalconInvalidStateError(errorMessage, messageStack, pvError);
case PvStatus.RUNTIME_ERROR:
return new FalconRuntimeError(errorMessage, messageStack, pvError);
case PvStatus.ACTIVATION_ERROR:
return new FalconActivationError(errorMessage, messageStack, pvError);
case PvStatus.ACTIVATION_LIMIT_REACHED:
return new FalconActivationLimitReachedError(
errorMessage,
messageStack,
pvError
);
case PvStatus.ACTIVATION_THROTTLED:
return new FalconActivationThrottledError(
errorMessage,
messageStack,
pvError
);
case PvStatus.ACTIVATION_REFUSED:
return new FalconActivationRefusedError(
errorMessage,
messageStack,
pvError
);
default:
// eslint-disable-next-line no-console
console.warn(`Unmapped error code: ${pvStatus}`);
return new FalconError(pvStatus, errorMessage);
}
}
281 changes: 281 additions & 0 deletions binding/web/src/falcon_worker.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,281 @@
/*
Copyright 2024 Picovoice Inc.
You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE"
file accompanying this source.
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 PvWorker from 'web-worker:./falcon_worker_handler.ts';

import {
FalconModel,
FalconSegments,
FalconWorkerInitResponse,
FalconWorkerProcessResponse,
FalconWorkerReleaseResponse,
PvStatus,
} from './types';
import { loadModel } from '@picovoice/web-utils';
import { pvStatusToException } from './falcon_errors';

export class FalconWorker {
private readonly _worker: Worker;
private readonly _version: string;
private readonly _sampleRate: number;

private static _wasm: string;
private static _wasmSimd: string;
private static _sdk: string = 'web';

private constructor(worker: Worker, version: string, sampleRate: number) {
this._worker = worker;
this._version = version;
this._sampleRate = sampleRate;
}

/**
* Get Falcon engine version.
*/
get version(): string {
return this._version;
}

/**
* Get sample rate.
*/
get sampleRate(): number {
return this._sampleRate;
}

/**
* Get Falcon worker instance.
*/
get worker(): Worker {
return this._worker;
}

/**
* Set base64 wasm file.
* @param wasm Base64'd wasm file to use to initialize wasm.
*/
public static setWasm(wasm: string): void {
if (this._wasm === undefined) {
this._wasm = wasm;
}
}

/**
* Set base64 wasm file with SIMD feature.
* @param wasmSimd Base64'd wasm file to use to initialize wasm.
*/
public static setWasmSimd(wasmSimd: string): void {
if (this._wasmSimd === undefined) {
this._wasmSimd = wasmSimd;
}
}

public static setSdk(sdk: string): void {
FalconWorker._sdk = sdk;
}

/**
* Creates a worker instance of the Picovoice Falcon Speech-to-Text engine.
* Behind the scenes, it requires the WebAssembly code to load and initialize before
* it can create an instance.
*
* @param accessKey AccessKey obtained from Picovoice Console (https://console.picovoice.ai/)
* @param model Falcon model options.
* @param model.base64 The model in base64 string to initialize Falcon.
* @param model.publicPath The model path relative to the public directory.
* @param model.customWritePath Custom path to save the model in storage.
* Set to a different name to use multiple models across `falcon` instances.
* @param model.forceWrite Flag to overwrite the model in storage even if it exists.
* @param model.version Version of the model file. Increment to update the model file in storage.
*
* @returns An instance of FalconWorker.
*/
public static async create(
accessKey: string,
model: FalconModel
): Promise<FalconWorker> {
const customWritePath = model.customWritePath
? model.customWritePath
: 'falcon_model';
const modelPath = await loadModel({ ...model, customWritePath });

const worker = new PvWorker();
const returnPromise: Promise<FalconWorker> = new Promise(
(resolve, reject) => {
// @ts-ignore - block from GC
this.worker = worker;
worker.onmessage = (
event: MessageEvent<FalconWorkerInitResponse>
): void => {
switch (event.data.command) {
case 'ok':
resolve(
new FalconWorker(
worker,
event.data.version,
event.data.sampleRate
)
);
break;
case 'failed':
case 'error':
reject(
pvStatusToException(
event.data.status,
event.data.shortMessage,
event.data.messageStack
)
);
break;
default:
reject(
pvStatusToException(
PvStatus.RUNTIME_ERROR,
// @ts-ignore
`Unrecognized command: ${event.data.command}`
)
);
}
};
}
);

worker.postMessage({
command: 'init',
accessKey: accessKey,
modelPath: modelPath,
wasm: this._wasm,
wasmSimd: this._wasmSimd,
sdk: this._sdk,
});

return returnPromise;
}

/**
* Processes audio in a worker. The required sample rate can be retrieved from '.sampleRate'.
* The audio needs to be 16-bit linearly-encoded. Furthermore, the engine operates on single-channel audio.
*
* @param pcm Frame of audio with properties described above.
* @param options Optional process arguments.
* @param options.transfer Flag to indicate if the buffer should be transferred or not. If set to true,
* input buffer array will be transferred to the worker.
* @param options.transferCallback Optional callback containing a new Int16Array with contents from 'pcm'. Use this callback
* to get the input pcm when using transfer.
*
* @return A transcript object.
*/
public process(
pcm: Int16Array,
options: {
transfer?: boolean;
transferCallback?: (data: Int16Array) => void;
} = {}
): Promise<FalconSegments> {
const { transfer = false, transferCallback } = options;

const returnPromise: Promise<FalconSegments> = new Promise(
(resolve, reject) => {
this._worker.onmessage = (
event: MessageEvent<FalconWorkerProcessResponse>
): void => {
switch (event.data.command) {
case 'ok':
if (transfer && transferCallback && event.data.inputFrame) {
transferCallback(new Int16Array(event.data.inputFrame.buffer));
}
resolve(event.data.result);
break;
case 'failed':
case 'error':
reject(
pvStatusToException(
event.data.status,
event.data.shortMessage,
event.data.messageStack
)
);
break;
default:
reject(
pvStatusToException(
PvStatus.RUNTIME_ERROR,
// @ts-ignore
`Unrecognized command: ${event.data.command}`
)
);
}
};
}
);

const transferable = transfer ? [pcm.buffer] : [];

this._worker.postMessage(
{
command: 'process',
inputFrame: pcm,
transfer: transfer,
},
transferable
);

return returnPromise;
}

/**
* Releases resources acquired by WebAssembly module.
*/
public release(): Promise<void> {
const returnPromise: Promise<void> = new Promise((resolve, reject) => {
this._worker.onmessage = (
event: MessageEvent<FalconWorkerReleaseResponse>
): void => {
switch (event.data.command) {
case 'ok':
resolve();
break;
case 'failed':
case 'error':
reject(
pvStatusToException(
event.data.status,
event.data.shortMessage,
event.data.messageStack
)
);
break;
default:
reject(
pvStatusToException(
PvStatus.RUNTIME_ERROR,
// @ts-ignore
`Unrecognized command: ${event.data.command}`
)
);
}
};
});

this._worker.postMessage({
command: 'release',
});

return returnPromise;
}

/**
* Terminates the active worker. Stops all requests being handled by worker.
*/
public terminate(): void {
this._worker.terminate();
}
}
133 changes: 133 additions & 0 deletions binding/web/src/falcon_worker_handler.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
/*
Copyright 2024 Picovoice Inc.
You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE"
file accompanying this source.
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.
*/

/// <reference no-default-lib="false"/>
/// <reference lib="webworker" />

import { Falcon } from './falcon';
import {
FalconWorkerInitRequest,
FalconWorkerProcessRequest,
FalconWorkerRequest,
PvStatus,
} from './types';
import { FalconError } from './falcon_errors';

let falcon: Falcon | null = null;

const initRequest = async (request: FalconWorkerInitRequest): Promise<any> => {
if (falcon !== null) {
return {
command: 'error',
status: PvStatus.INVALID_STATE,
shortMessage: 'Falcon already initialized',
};
}
try {
Falcon.setWasm(request.wasm);
Falcon.setWasmSimd(request.wasmSimd);
Falcon.setSdk(request.sdk);
falcon = await Falcon._init(request.accessKey, request.modelPath);
return {
command: 'ok',
version: falcon.version,
sampleRate: falcon.sampleRate,
};
} catch (e: any) {
if (e instanceof FalconError) {
return {
command: 'error',
status: e.status,
shortMessage: e.shortMessage,
messageStack: e.messageStack,
};
}
return {
command: 'error',
status: PvStatus.RUNTIME_ERROR,
shortMessage: e.message,
};
}
};

const processRequest = async (
request: FalconWorkerProcessRequest
): Promise<any> => {
if (falcon === null) {
return {
command: 'error',
status: PvStatus.INVALID_STATE,
shortMessage: 'Falcon not initialized',
inputFrame: request.inputFrame,
};
}
try {
return {
command: 'ok',
result: await falcon.process(request.inputFrame),
inputFrame: request.transfer ? request.inputFrame : undefined,
};
} catch (e: any) {
if (e instanceof FalconError) {
return {
command: 'error',
status: e.status,
shortMessage: e.shortMessage,
messageStack: e.messageStack,
};
}
return {
command: 'error',
status: PvStatus.RUNTIME_ERROR,
shortMessage: e.message,
};
}
};

const releaseRequest = async (): Promise<any> => {
if (falcon !== null) {
await falcon.release();
falcon = null;
close();
}
return {
command: 'ok',
};
};

/**
* Falcon worker handler.
*/
self.onmessage = async function (
event: MessageEvent<FalconWorkerRequest>
): Promise<void> {
switch (event.data.command) {
case 'init':
self.postMessage(await initRequest(event.data));
break;
case 'process':
self.postMessage(
await processRequest(event.data),
event.data.transfer ? [event.data.inputFrame.buffer] : []
);
break;
case 'release':
self.postMessage(await releaseRequest());
break;
default:
self.postMessage({
command: 'failed',
status: PvStatus.RUNTIME_ERROR,
// @ts-ignore
shortMessage: `Unrecognized command: ${event.data.command}`,
});
}
};
44 changes: 44 additions & 0 deletions binding/web/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { Falcon } from './falcon';
import { FalconWorker } from './falcon_worker';
import * as FalconErrors from './falcon_errors';

import {
FalconModel,
FalconSegment,
FalconSegments,
FalconWorkerInitRequest,
FalconWorkerProcessRequest,
FalconWorkerReleaseRequest,
FalconWorkerRequest,
FalconWorkerInitResponse,
FalconWorkerProcessResponse,
FalconWorkerReleaseResponse,
FalconWorkerFailureResponse,
FalconWorkerResponse,
} from './types';

import falconWasm from '../lib/pv_falcon.wasm';
import falconWasmSimd from '../lib/pv_falcon_simd.wasm';

Falcon.setWasm(falconWasm);
Falcon.setWasmSimd(falconWasmSimd);
FalconWorker.setWasm(falconWasm);
FalconWorker.setWasmSimd(falconWasmSimd);

export {
Falcon,
FalconErrors,
FalconModel,
FalconSegment,
FalconSegments,
FalconWorker,
FalconWorkerInitRequest,
FalconWorkerProcessRequest,
FalconWorkerReleaseRequest,
FalconWorkerRequest,
FalconWorkerInitResponse,
FalconWorkerProcessResponse,
FalconWorkerReleaseResponse,
FalconWorkerFailureResponse,
FalconWorkerResponse,
};
105 changes: 105 additions & 0 deletions binding/web/src/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
/*
Copyright 2024 Picovoice Inc.
You may not use this file except in compliance with the license. A copy of the license is located in the "LICENSE"
file accompanying this source.
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 { PvModel } from '@picovoice/web-utils';

export enum PvStatus {
SUCCESS = 10000,
OUT_OF_MEMORY,
IO_ERROR,
INVALID_ARGUMENT,
STOP_ITERATION,
KEY_ERROR,
INVALID_STATE,
RUNTIME_ERROR,
ACTIVATION_ERROR,
ACTIVATION_LIMIT_REACHED,
ACTIVATION_THROTTLED,
ACTIVATION_REFUSED,
}

/**
* FalconModel types
*/
export type FalconModel = PvModel;

export type FalconSegment = {
/** Start of word in seconds. */
startSec: number;
/** End of word in seconds. */
endSec: number;
/** The speaker tag is `-1` if diarization is not enabled during initialization;
* otherwise, it's a non-negative integer identifying unique speakers, with `0` reserved for
* unknown speakers. */
speakerTag: number;
};

export type FalconSegments = {
segments: FalconSegment[];
};

export type FalconWorkerInitRequest = {
command: 'init';
accessKey: string;
modelPath: string;
wasm: string;
wasmSimd: string;
sdk: string;
};

export type FalconWorkerProcessRequest = {
command: 'process';
inputFrame: Int16Array;
transfer: boolean;
};

export type FalconWorkerReleaseRequest = {
command: 'release';
};

export type FalconWorkerRequest =
| FalconWorkerInitRequest
| FalconWorkerProcessRequest
| FalconWorkerReleaseRequest;

export type FalconWorkerFailureResponse = {
command: 'failed' | 'error';
status: PvStatus;
shortMessage: string;
messageStack: string[];
};

export type FalconWorkerInitResponse =
| FalconWorkerFailureResponse
| {
command: 'ok';
sampleRate: number;
version: string;
};

export type FalconWorkerProcessResponse =
| FalconWorkerFailureResponse
| {
command: 'ok';
result: FalconSegments;
inputFrame?: Int16Array;
};

export type FalconWorkerReleaseResponse =
| FalconWorkerFailureResponse
| {
command: 'ok';
};

export type FalconWorkerResponse =
| FalconWorkerInitResponse
| FalconWorkerProcessResponse
| FalconWorkerReleaseResponse;
256 changes: 37 additions & 219 deletions binding/web/test/falcon.test.ts
Original file line number Diff line number Diff line change
@@ -1,72 +1,28 @@
import { Leopard, LeopardWorker } from '../';
import { Falcon, FalconWorker } from '../';
import testData from './test_data.json';

// @ts-ignore
import falconParams from './falcon_params';
import { PvModel } from '@picovoice/web-utils';
import { LeopardWord } from '../src';
import { LeopardError } from '../src/falcon_errors';

const ACCESS_KEY: string = Cypress.env('ACCESS_KEY');

const levenshteinDistance = (words1: string[], words2: string[]) => {
const res = Array.from(
Array(words1.length + 1),
() => new Array(words2.length + 1)
);
for (let i = 0; i <= words1.length; i++) {
res[i][0] = i;
}
for (let j = 0; j <= words2.length; j++) {
res[0][j] = j;
}
for (let i = 1; i <= words1.length; i++) {
for (let j = 1; j <= words2.length; j++) {
res[i][j] = Math.min(
res[i - 1][j] + 1,
res[i][j - 1] + 1,
res[i - 1][j - 1] +
(words1[i - 1].toUpperCase() === words2[j - 1].toUpperCase() ? 0 : 1)
);
}
}
return res[words1.length][words2.length];
};
import { FalconSegment } from '../src';
import { FalconError } from '../src/falcon_errors';

const wordErrorRate = (
reference: string,
hypothesis: string,
useCER = false
): number => {
const splitter = useCER ? '' : ' ';
const ed = levenshteinDistance(
reference.split(splitter),
hypothesis.split(splitter)
);
return ed / reference.length;
};
const ACCESS_KEY = Cypress.env('ACCESS_KEY');

const validateMetadata = (
words: LeopardWord[],
expectedWords: LeopardWord[],
enableDiarization: boolean
words: FalconSegment[],
expectedSegments: FalconSegment[]
) => {
expect(words.length).to.be.eq(expectedWords.length);
expect(words.length).to.be.eq(expectedSegments.length);
for (let i = 0; i < words.length; i += 1) {
expect(words[i].word).to.be.eq(expectedWords[i].word);
expect(words[i].startSec).to.be.closeTo(expectedWords[i].startSec, 0.1);
expect(words[i].endSec).to.be.closeTo(expectedWords[i].endSec, 0.1);
expect(words[i].confidence).to.be.closeTo(expectedWords[i].confidence, 0.1);
if (enableDiarization) {
expect(words[i].speakerTag).to.be.eq(expectedWords[i].speakerTag);
} else {
expect(words[i].speakerTag).to.be.eq(-1);
}
expect(words[i].startSec).to.be.closeTo(expectedSegments[i].startSec, 0.1);
expect(words[i].endSec).to.be.closeTo(expectedSegments[i].endSec, 0.1);
expect(words[i].speakerTag).to.be.eq(expectedSegments[i].speakerTag);
}
};

const runInitTest = async (
instance: typeof Leopard | typeof LeopardWorker,
instance: typeof Falcon | typeof FalconWorker,
params: {
accessKey?: string;
model?: PvModel;
@@ -87,7 +43,7 @@ const runInitTest = async (
expect(typeof falcon.version).to.eq('string');
expect(falcon.version.length).to.be.greaterThan(0);

if (falcon instanceof LeopardWorker) {
if (falcon instanceof FalconWorker) {
falcon.terminate();
} else {
await falcon.release();
@@ -106,40 +62,27 @@ const runInitTest = async (
};

const runProcTest = async (
instance: typeof Leopard | typeof LeopardWorker,
instance: typeof Falcon | typeof FalconWorker,
inputPcm: Int16Array,
expectedTranscript: string,
expectedErrorRate: number,
expectedWords: LeopardWord[],
expectedSegments: FalconSegment[],
params: {
accessKey?: string;
model?: PvModel;
enablePunctuation?: boolean;
enableDiarization?: boolean;
useCER?: boolean;
} = {}
) => {
const {
accessKey = ACCESS_KEY,
model = { publicPath: '/test/falcon_params.pv', forceWrite: true },
enablePunctuation = false,
enableDiarization = false,
useCER = false,
} = params;

try {
const falcon = await instance.create(accessKey, model, {
enableAutomaticPunctuation: enablePunctuation,
enableDiarization: enableDiarization,
});
const falcon = await instance.create(accessKey, model);

const { transcript, words } = await falcon.process(inputPcm);
const errorRate = wordErrorRate(expectedTranscript, transcript, useCER);
expect(errorRate).to.be.lt(expectedErrorRate);
const { segments } = await falcon.process(inputPcm);

validateMetadata(words, expectedWords, enableDiarization);
validateMetadata(segments, expectedSegments);

if (falcon instanceof LeopardWorker) {
if (falcon instanceof FalconWorker) {
falcon.terminate();
} else {
await falcon.release();
@@ -149,11 +92,11 @@ const runProcTest = async (
}
};

describe('Leopard Binding', function () {
describe('Falcon Binding', function () {
it(`should return process error message stack`, async () => {
let error: LeopardError | null = null;
let error: FalconError | null = null;

const falcon = await Leopard.create(ACCESS_KEY, {
const falcon = await Falcon.create(ACCESS_KEY, {
publicPath: '/test/falcon_params.pv',
forceWrite: true,
});
@@ -167,7 +110,7 @@ describe('Leopard Binding', function () {
try {
await falcon.process(testPcm);
} catch (e) {
error = e as LeopardError;
error = e as FalconError;
}

// @ts-ignore
@@ -176,13 +119,13 @@ describe('Leopard Binding', function () {

expect(error).to.not.be.null;
if (error) {
expect((error as LeopardError).messageStack.length).to.be.gt(0);
expect((error as LeopardError).messageStack.length).to.be.lte(8);
expect((error as FalconError).messageStack.length).to.be.gt(0);
expect((error as FalconError).messageStack.length).to.be.lte(8);
}
});

for (const instance of [Leopard, LeopardWorker]) {
const instanceString = instance === LeopardWorker ? 'worker' : 'main';
for (const instance of [Falcon, FalconWorker]) {
const instanceString = instance === FalconWorker ? 'worker' : 'main';

it(`should return correct error message stack (${instanceString})`, async () => {
let messageStack = [];
@@ -263,144 +206,20 @@ describe('Leopard Binding', function () {
});
});

// for (const testParam of testData.tests.language_tests) {
// it(`should be able to process (${testParam.language}) (${instanceString})`, () => {
// try {
// cy.getFramesFromFile(`audio_samples/${testParam.audio_file}`).then(
// async pcm => {
// const suffix =
// testParam.language === 'en' ? '' : `_${testParam.language}`;
// await runProcTest(
// instance,
// pcm,
// testParam.transcript,
// testParam.error_rate,
// testParam.words.map((w: any) => ({
// word: w.word,
// startSec: w.start_sec,
// endSec: w.end_sec,
// confidence: w.confidence,
// speakerTag: w.speaker_tag,
// })),
// {
// model: {
// publicPath: `/test/falcon_params${suffix}.pv`,
// forceWrite: true,
// },
// useCER: testParam.language === 'ja',
// }
// );
// }
// );
// } catch (e) {
// expect(e).to.be.undefined;
// }
// });
//
// it(`should be able to process with punctuation (${testParam.language}) (${instanceString})`, () => {
// try {
// cy.getFramesFromFile(`audio_samples/${testParam.audio_file}`).then(
// async pcm => {
// const suffix =
// testParam.language === 'en' ? '' : `_${testParam.language}`;
// await runProcTest(
// instance,
// pcm,
// testParam.transcript_with_punctuation,
// testParam.error_rate,
// testParam.words.map((w: any) => ({
// word: w.word,
// startSec: w.start_sec,
// endSec: w.end_sec,
// confidence: w.confidence,
// speakerTag: w.speaker_tag,
// })),
// {
// model: {
// publicPath: `/test/falcon_params${suffix}.pv`,
// forceWrite: true,
// },
// enablePunctuation: true,
// useCER: testParam.language === 'ja',
// }
// );
// }
// );
// } catch (e) {
// expect(e).to.be.undefined;
// }
// });

it(`should be able to process with diarization (${testParam.language}) (${instanceString})`, () => {
for (const testParam of testData.tests.diarization_tests) {
it(`should be able to process (${instanceString})`, () => {
try {
cy.getFramesFromFile(`audio_samples/${testParam.audio_file}`).then(
async pcm => {
const suffix =
testParam.language === 'en' ? '' : `_${testParam.language}`;
await runProcTest(
instance,
pcm,
testParam.transcript,
testParam.error_rate,
testParam.words.map((w: any) => ({
word: w.word,
startSec: w.start_sec,
endSec: w.end_sec,
confidence: w.confidence,
speakerTag: w.speaker_tag,
})),
{
model: {
publicPath: `/test/falcon_params${suffix}.pv`,
forceWrite: true,
},
enableDiarization: true,
useCER: testParam.language === 'ja',
}
);
}
);
} catch (e) {
expect(e).to.be.undefined;
}
});
}

for (const testParam of testData.tests.diarization_tests) {
it(`should be able to process diarization multiple speakers (${testParam.language}) (${instanceString})`, () => {
try {
cy.getFramesFromFile(`audio_samples/${testParam.audio_file}`).then(
async pcm => {
const suffix =
testParam.language === 'en' ? '' : `_${testParam.language}`;

const falcon = await instance.create(
ACCESS_KEY,
{
publicPath: `/test/falcon_params${suffix}.pv`,
forceWrite: true,
},
{
enableDiarization: true,
}
testParam.segments.map((s: any) => ({
startSec: s.start_sec,
endSec: s.end_sec,
speakerTag: s.speaker_tag,
}))
);

const { words } = await falcon.process(pcm);

expect(words.length).to.eq(testParam.words.length);

for (let i = 0; i < words.length; i++) {
expect(words[i].word).to.eq(testParam.words[i].word);
expect(words[i].speakerTag).to.eq(
testParam.words[i].speaker_tag
);
}

if (falcon instanceof Leopard) {
await falcon.release();
} else {
falcon.terminate();
}
}
);
} catch (e) {
@@ -412,11 +231,10 @@ describe('Leopard Binding', function () {
it(`should be able to transfer buffer`, () => {
try {
cy.getFramesFromFile(`audio_samples/test.wav`).then(async pcm => {
const falcon = await LeopardWorker.create(
ACCESS_KEY,
{ publicPath: '/test/falcon_params.pv', forceWrite: true },
{ enableAutomaticPunctuation: false }
);
const falcon = await FalconWorker.create(ACCESS_KEY, {
publicPath: '/test/falcon_params.pv',
forceWrite: true,
});

let copy = new Int16Array(pcm.length);
copy.set(pcm);
42 changes: 24 additions & 18 deletions binding/web/test/falcon_perf.test.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import { Leopard, LeopardWorker } from "../";
import { Falcon, FalconWorker } from '../';

const ACCESS_KEY = Cypress.env('ACCESS_KEY');
const NUM_TEST_ITERATIONS = Number(Cypress.env('NUM_TEST_ITERATIONS'));
const INIT_PERFORMANCE_THRESHOLD_SEC = Number(Cypress.env('INIT_PERFORMANCE_THRESHOLD_SEC'));
const PROC_PERFORMANCE_THRESHOLD_SEC = Number(Cypress.env('PROC_PERFORMANCE_THRESHOLD_SEC'));
const INIT_PERFORMANCE_THRESHOLD_SEC = Number(
Cypress.env('INIT_PERFORMANCE_THRESHOLD_SEC')
);
const PROC_PERFORMANCE_THRESHOLD_SEC = Number(
Cypress.env('PROC_PERFORMANCE_THRESHOLD_SEC')
);

async function testPerformance(
instance: typeof Leopard | typeof LeopardWorker,
instance: typeof Falcon | typeof FalconWorker,
inputPcm: Int16Array
) {
const initPerfResults: number[] = [];
@@ -15,28 +19,30 @@ async function testPerformance(
for (let j = 0; j < NUM_TEST_ITERATIONS; j++) {
let start = Date.now();

const leopard = await instance.create(
ACCESS_KEY,
{ publicPath: '/test/leopard_params.pv', forceWrite: true }
);
const falcon = await instance.create(ACCESS_KEY, {
publicPath: '/test/falcon_params.pv',
forceWrite: true,
});

let end = Date.now();
initPerfResults.push((end - start) / 1000);

start = Date.now();
await leopard.process(inputPcm);
await falcon.process(inputPcm);
end = Date.now();
procPerfResults.push((end - start) / 1000);

if (leopard instanceof LeopardWorker) {
leopard.terminate();
if (falcon instanceof FalconWorker) {
falcon.terminate();
} else {
await leopard.release();
await falcon.release();
}
}

const initAvgPerf = initPerfResults.reduce((a, b) => a + b) / NUM_TEST_ITERATIONS;
const procAvgPerf = procPerfResults.reduce((a, b) => a + b) / NUM_TEST_ITERATIONS;
const initAvgPerf =
initPerfResults.reduce((a, b) => a + b) / NUM_TEST_ITERATIONS;
const procAvgPerf =
procPerfResults.reduce((a, b) => a + b) / NUM_TEST_ITERATIONS;

// eslint-disable-next-line no-console
console.log(`Average init performance: ${initAvgPerf} seconds`);
@@ -47,14 +53,14 @@ async function testPerformance(
expect(procAvgPerf).to.be.lessThan(PROC_PERFORMANCE_THRESHOLD_SEC);
}

describe('Leopard binding performance test', () => {
describe('Falcon binding performance test', () => {
Cypress.config('defaultCommandTimeout', 160000);

for (const instance of [Leopard, LeopardWorker]) {
const instanceString = (instance === LeopardWorker) ? 'worker' : 'main';
for (const instance of [Falcon, FalconWorker]) {
const instanceString = instance === FalconWorker ? 'worker' : 'main';

it(`should be lower than performance threshold (${instanceString})`, () => {
cy.getFramesFromFile('audio_samples/test.wav').then( async inputPcm => {
cy.getFramesFromFile('audio_samples/test.wav').then(async inputPcm => {
await testPerformance(instance, inputPcm);
});
});
4,136 changes: 4,136 additions & 0 deletions binding/web/yarn.lock

Large diffs are not rendered by default.