├── .clang-format
├── .gitignore
├── .husky
└── pre-commit
├── karma.unit.js
├── tsconfig.json
├── .eslintrc.json
├── src
├── index.ts
├── dictation_device.ts
├── test_util
│ ├── clean_state.ts
│ ├── check_button_mapping.ts
│ ├── fake_hid_device.ts
│ └── fake_hid_api.ts
├── foot_control_device.ts
├── powermic_3_device.ts
├── foot_control_device_test.ts
├── speechmike_gamepad_device.ts
├── powermic_3_device_test.ts
├── dictation_device_base_test.ts
├── dictation_device_base.ts
├── speechmike_gamepad_device_test.ts
├── dictation_device_manager.ts
├── dictation_device_manager_test.ts
├── speechmike_hid_device.ts
└── speechmike_hid_device_test.ts
├── CONTRIBUTING.md
├── package.json
├── webpack.config.js
├── README.md
├── example
└── index.ejs
└── LICENSE
/.clang-format:
--------------------------------------------------------------------------------
1 | BasedOnStyle: Google
2 |
--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
1 | node_modules
2 | dist
3 | /.vs
4 |
--------------------------------------------------------------------------------
/.husky/pre-commit:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env sh
2 | . "$(dirname -- "$0")/_/husky.sh"
3 |
4 | npm run lint
5 | npm run formatCheck
6 | npm test
7 |
--------------------------------------------------------------------------------
/karma.unit.js:
--------------------------------------------------------------------------------
1 | var webpackConfig = require('./webpack.config');
2 |
3 | process.env.CHROME_BIN = require('puppeteer').executablePath();
4 |
5 | module.exports = function(config) {
6 | config.set({
7 | basePath: '',
8 | frameworks: ['jasmine'],
9 | files: [
10 | 'src/**/*_test.ts',
11 | 'src/test_util/**/*.ts',
12 | ],
13 | exclude: [],
14 | preprocessors: {
15 | '**/*.ts': ['webpack'],
16 | },
17 | webpack: webpackConfig,
18 | reporters: ['progress'],
19 | port: 9876,
20 | colors: true,
21 | logLevel: config.LOG_INFO,
22 | autoWatch: false,
23 | browsers: ['ChromeHeadless'],
24 | singleRun: true,
25 | concurrency: Infinity
26 | })
27 | }
28 |
--------------------------------------------------------------------------------
/tsconfig.json:
--------------------------------------------------------------------------------
1 | {
2 | "compileOnSave": true,
3 | "compilerOptions": {
4 | "strict": true,
5 | "forceConsistentCasingInFileNames": true,
6 | "esModuleInterop": true,
7 | "outDir": "./dist/out-tsc",
8 | "sourceMap": true,
9 | "declaration": true,
10 | "moduleResolution": "node",
11 | "emitDecoratorMetadata": true,
12 | "experimentalDecorators": true,
13 | "target": "ES2017",
14 | "module": "CommonJS",
15 | "lib": [
16 | "es2017",
17 | "DOM"
18 | ],
19 | "typeRoots": [
20 | "node_modules/@types"
21 | ],
22 | "noUnusedLocals": true,
23 | "noUnusedParameters": true
24 | },
25 | "include": [
26 | "src/**/*.ts"
27 | ],
28 | "exclude": [
29 | "node_modules",
30 | "src/**/*_test.ts",
31 | "src/test_util/**/*"
32 | ]
33 | }
34 |
--------------------------------------------------------------------------------
/.eslintrc.json:
--------------------------------------------------------------------------------
1 | {
2 | "parser": "@typescript-eslint/parser",
3 | "parserOptions": {
4 | "ecmaVersion": 12,
5 | "sourceType": "module"
6 | },
7 | "plugins": [
8 | "@typescript-eslint"
9 | ],
10 | "extends": [
11 | "eslint:recommended",
12 | "plugin:@typescript-eslint/recommended",
13 | "prettier"
14 | ],
15 | "rules": {
16 | "@typescript-eslint/no-namespace": "off",
17 | "@typescript-eslint/no-unused-vars": [
18 | "error",
19 | {
20 | "argsIgnorePattern": "^_",
21 | "varsIgnorePattern": "^_",
22 | "caughtErrorsIgnorePattern": "^_"
23 | }
24 | ],
25 | "@typescript-eslint/consistent-type-definitions": [
26 | "error",
27 | "type"
28 | ]
29 | },
30 | "env": {
31 | "browser": true,
32 | "es2021": true
33 | }
34 | }
35 |
--------------------------------------------------------------------------------
/src/index.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | export * from './dictation_device_base';
19 | export * from './dictation_device_manager';
20 | export * from './foot_control_device';
21 | export * from './powermic_3_device';
22 | export * from './speechmike_gamepad_device';
23 | export * from './speechmike_hid_device';
24 |
--------------------------------------------------------------------------------
/src/dictation_device.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {FootControlDevice} from './foot_control_device';
19 | import {PowerMic3Device} from './powermic_3_device';
20 | import {SpeechMikeGamepadDevice} from './speechmike_gamepad_device';
21 | import {SpeechMikeHidDevice} from './speechmike_hid_device';
22 |
23 | export type DictationDevice =|SpeechMikeHidDevice|SpeechMikeGamepadDevice|
24 | PowerMic3Device|FootControlDevice;
25 |
--------------------------------------------------------------------------------
/CONTRIBUTING.md:
--------------------------------------------------------------------------------
1 | # How to Contribute
2 |
3 | We'd love to accept your patches and contributions to this project. There are
4 | just a few small guidelines you need to follow.
5 |
6 | ## Contributor License Agreement
7 |
8 | Contributions to this project must be accompanied by a Contributor License
9 | Agreement (CLA). You (or your employer) retain the copyright to your
10 | contribution; this simply gives us permission to use and redistribute your
11 | contributions as part of the project. Head over to
12 | to see your current agreements on file or
13 | to sign a new one.
14 |
15 | You generally only need to submit a CLA once, so if you've already submitted one
16 | (even if it was for a different project), you probably don't need to do it
17 | again.
18 |
19 | ## Code Reviews
20 |
21 | All submissions, including submissions by project members, require review. We
22 | use GitHub pull requests for this purpose. Consult
23 | [GitHub Help](https://help.github.com/articles/about-pull-requests/) for more
24 | information on using pull requests.
25 |
26 | ## Community Guidelines
27 |
28 | This project follows
29 | [Google's Open Source Community Guidelines](https://opensource.google/conduct/).
30 |
--------------------------------------------------------------------------------
/src/test_util/clean_state.ts:
--------------------------------------------------------------------------------
1 | class JasmineTracker {
2 | private specDepth = 0;
3 |
4 | specStarted() {
5 | this.specDepth++;
6 | }
7 |
8 | specDone() {
9 | this.specDepth--;
10 | }
11 |
12 | currentlyInSpec() {
13 | return !!this.specDepth;
14 | }
15 | }
16 |
17 | const jasmineTracker = new JasmineTracker();
18 | jasmine.getEnv().addReporter(jasmineTracker);
19 |
20 | function assertNotInSpec(callerName: string): void {
21 | if (jasmineTracker.currentlyInSpec()) {
22 | throw new Error(
23 | `${callerName} must not be called from beforeEach, it, etc`);
24 | }
25 | }
26 |
27 | /* Returns an object of type State that is cleaned-up for every test case using
28 | * beforeEach() to avoid leaking state across test cases. */
29 | export function cleanState>(): State {
30 | assertNotInSpec('cleanState');
31 | const state = {} as State;
32 | beforeEach(() => {
33 | // If there was no existing state (ie: this was called by `cleanState`),
34 | // then clear state before every test case.
35 | for (const prop of Object.getOwnPropertyNames(state)) {
36 | delete (state as {[k: string]: unknown})[prop];
37 | }
38 | });
39 | return state;
40 | }
41 |
--------------------------------------------------------------------------------
/src/foot_control_device.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {ButtonEvent, DeviceType, DictationDeviceBase, ImplementationType,} from './dictation_device_base';
19 |
20 | const BUTTON_MAPPINGS = new Map([
21 | [ButtonEvent.REWIND, 1 << 0],
22 | [ButtonEvent.PLAY, 1 << 1],
23 | [ButtonEvent.FORWARD, 1 << 2],
24 | [ButtonEvent.EOL_PRIO, 1 << 3],
25 | ]);
26 |
27 | export class FootControlDevice extends DictationDeviceBase {
28 | readonly implType = ImplementationType.FOOT_CONTROL;
29 |
30 | static create(hidDevice: HIDDevice) {
31 | return new FootControlDevice(hidDevice);
32 | }
33 |
34 | getDeviceType(): DeviceType {
35 | if (this.hidDevice.vendorId === 0x0911) {
36 | if (this.hidDevice.productId === 0x1844) {
37 | return DeviceType.FOOT_CONTROL_ACC_2310_2320;
38 | } else if (this.hidDevice.productId === 0x091a) {
39 | return DeviceType.FOOT_CONTROL_ACC_2330;
40 | }
41 | return DeviceType.UNKNOWN;
42 | }
43 | return DeviceType.UNKNOWN;
44 | }
45 |
46 | protected getButtonMappings(): Map {
47 | return BUTTON_MAPPINGS;
48 | }
49 |
50 | protected getInputBitmask(data: DataView): number {
51 | return data.getUint8(0);
52 | }
53 |
54 | protected getThisAsDictationDevice(): FootControlDevice {
55 | return this;
56 | }
57 | }
58 |
--------------------------------------------------------------------------------
/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "dictation_support",
3 | "version": "1.0.5",
4 | "description": "SDK to interact with dictation devices",
5 | "scripts": {
6 | "build": "rimraf dist && npx webpack --config=webpack.config.js",
7 | "lint": "eslint --ignore-path .gitignore --ext .ts .",
8 | "format": "git clang-format",
9 | "formatCheck": "git clang-format --diff",
10 | "prepare": "husky install",
11 | "test": "karma start karma.unit.js"
12 | },
13 | "author": "hendrich@google.com",
14 | "license": "Apache-2.0",
15 | "main": "dist/index.js",
16 | "types": "dist/index.d.ts",
17 | "files": [
18 | "/dist/index.js",
19 | "/dist/index.d.ts"
20 | ],
21 | "repository": {
22 | "type": "git",
23 | "url": "https://github.com/GoogleChromeLabs/dictation_support.git"
24 | },
25 | "devDependencies": {
26 | "@types/jasmine": "^4.3.0",
27 | "@typescript-eslint/eslint-plugin": "^5.35.1",
28 | "@typescript-eslint/parser": "^5.35.1",
29 | "clang-format": "^1.8.0",
30 | "dts-bundle-webpack": "^1.0.2",
31 | "eslint": "^8.22.0",
32 | "eslint-config-prettier": "^8.5.0",
33 | "eslint-config-standard-with-typescript": "^22.0.0",
34 | "eslint-plugin-import": "^2.26.0",
35 | "eslint-plugin-n": "^15.2.5",
36 | "eslint-plugin-promise": "^6.0.1",
37 | "html-webpack-plugin": "^5.5.0",
38 | "husky": "^8.0.1",
39 | "jasmine": "^4.3.0",
40 | "karma": "^6.4.0",
41 | "karma-chrome-launcher": "^3.1.1",
42 | "karma-cli": "^2.0.0",
43 | "karma-jasmine": "^5.1.0",
44 | "karma-spec-reporter": "^0.0.34",
45 | "karma-webpack": "^5.0.0",
46 | "prettier": "^2.7.1",
47 | "puppeteer": "^17.0.0",
48 | "rimraf": "^3.0.2",
49 | "terser-webpack-plugin": "^5.3.3",
50 | "ts-loader": "^9.3.1",
51 | "typescript": "~4.7.0",
52 | "webpack": "^5.74.0",
53 | "webpack-cli": "^4.10.0",
54 | "webpack-dev-server": "^4.9.3"
55 | },
56 | "dependencies": {
57 | "@types/w3c-web-hid": "^1.0.3"
58 | }
59 | }
60 |
--------------------------------------------------------------------------------
/webpack.config.js:
--------------------------------------------------------------------------------
1 | /* eslint-disable no-undef */
2 | /* eslint-disable @typescript-eslint/no-var-requires */
3 | /**
4 | * @license
5 | * Copyright 2022 Google LLC
6 | *
7 | * Licensed under the Apache License, Version 2.0 (the "License");
8 | * you may not use this file except in compliance with the License.
9 | * You may obtain a copy of the License at
10 | *
11 | * http://www.apache.org/licenses/LICENSE-2.0
12 | *
13 | * Unless required by applicable law or agreed to in writing, software
14 | * distributed under the License is distributed on an "AS IS" BASIS,
15 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
16 | * See the License for the specific language governing permissions and
17 | * limitations under the License.
18 | */
19 |
20 | const HtmlWebpackPlugin = require('html-webpack-plugin');
21 | const path = require('path');
22 | const TerserPlugin = require('terser-webpack-plugin');
23 | const DtsBundlePlugin = require('dts-bundle-webpack');
24 | const libraryName = 'DictationSupport';
25 |
26 | module.exports = {
27 | entry: './src/index.ts',
28 | output: {
29 | path: path.resolve(__dirname, 'dist'),
30 | filename: 'index.js',
31 | library: libraryName,
32 | libraryTarget: 'umd',
33 | umdNamedDefine: true,
34 | },
35 | resolve : {extensions : ['.ts']},
36 | devtool : 'source-map',
37 | optimization : {
38 | minimize : true,
39 | minimizer : [new TerserPlugin()],
40 | },
41 | module : {
42 | rules :
43 | [
44 | {
45 | test : /\.ts?$/,
46 | use : 'ts-loader',
47 | exclude : /node_modules/,
48 | },
49 | ],
50 | },
51 | plugins: [
52 | new HtmlWebpackPlugin({
53 | template: path.resolve(__dirname, 'example/index.ejs'),
54 | inject: 'head',
55 | }),
56 | new DtsBundlePlugin({
57 | name: libraryName,
58 | main: 'dist/out-tsc/index.d.ts',
59 | out: '../index.d.ts',
60 | removeSource: true,
61 | outputAsModuleFolder: true, // to use npm in-package typings
62 | }),
63 | ],
64 | };
65 |
--------------------------------------------------------------------------------
/src/powermic_3_device.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {ButtonEvent, DeviceType, DictationDeviceBase, ImplementationType,} from './dictation_device_base';
19 |
20 | export enum LedStatePM3 {
21 | OFF = 0,
22 | RED = 1,
23 | GREEN = 2,
24 | }
25 |
26 | const BUTTON_MAPPINGS = new Map([
27 | [ButtonEvent.TRANSCRIBE, 1 << 0],
28 | [ButtonEvent.TAB_BACKWARD, 1 << 1],
29 | [ButtonEvent.RECORD, 1 << 2],
30 | [ButtonEvent.TAB_FORWARD, 1 << 3],
31 | [ButtonEvent.REWIND, 1 << 4],
32 | [ButtonEvent.FORWARD, 1 << 5],
33 | [ButtonEvent.PLAY, 1 << 6],
34 | [ButtonEvent.CUSTOM_LEFT, 1 << 7],
35 | [ButtonEvent.ENTER_SELECT, 1 << 8],
36 | [ButtonEvent.CUSTOM_RIGHT, 1 << 9],
37 | ]);
38 |
39 | export class PowerMic3Device extends DictationDeviceBase {
40 | readonly implType = ImplementationType.POWERMIC_3;
41 |
42 | static create(hidDevice: HIDDevice) {
43 | return new PowerMic3Device(hidDevice);
44 | }
45 |
46 | getDeviceType(): DeviceType {
47 | return DeviceType.POWERMIC_3;
48 | }
49 |
50 | async setLed(state: LedStatePM3) {
51 | const data = new Uint8Array([state]);
52 | await this.hidDevice.sendReport(/* reportId= */ 0, data);
53 | }
54 |
55 | protected getButtonMappings(): Map {
56 | return BUTTON_MAPPINGS;
57 | }
58 |
59 | protected getInputBitmask(data: DataView): number {
60 | return data.getUint16(1, /* littleEndian= */ true);
61 | }
62 |
63 | protected getThisAsDictationDevice(): PowerMic3Device {
64 | return this;
65 | }
66 | }
67 |
--------------------------------------------------------------------------------
/src/foot_control_device_test.ts:
--------------------------------------------------------------------------------
1 | import {ButtonEvent, DeviceType, ImplementationType} from './dictation_device_base';
2 | import {FootControlDevice} from './foot_control_device';
3 | import {ButtonMappingTestCase, checkButtonMapping} from './test_util/check_button_mapping';
4 | import {cleanState} from './test_util/clean_state';
5 | import {FakeHidDevice} from './test_util/fake_hid_device';
6 |
7 | describe('FootControlDevice', () => {
8 | const state = cleanState<{
9 | buttonEventListener: jasmine.Spy,
10 | dictationDevice: FootControlDevice,
11 | fakeHidDevice: FakeHidDevice,
12 | }>();
13 |
14 | beforeEach(() => {
15 | state.buttonEventListener = jasmine.createSpy('buttonEventListener');
16 | });
17 |
18 | async function createDictationDevice(productId: number) {
19 | state.fakeHidDevice = new FakeHidDevice({productId, vendorId: 0x0911});
20 | state.dictationDevice = FootControlDevice.create(state.fakeHidDevice);
21 | state.dictationDevice.addButtonEventListener(state.buttonEventListener);
22 | await state.dictationDevice.init();
23 | }
24 |
25 | describe('creates the right device type', () => {
26 | it('FOOT_CONTROL_ACC_2310_2320', async () => {
27 | await createDictationDevice(/* productId= */ 0x1844);
28 | expect(state.dictationDevice.getDeviceType())
29 | .toBe(DeviceType.FOOT_CONTROL_ACC_2310_2320);
30 | expect(state.dictationDevice.implType)
31 | .toBe(ImplementationType.FOOT_CONTROL);
32 | });
33 |
34 | it('FOOT_CONTROL_ACC_2330', async () => {
35 | await createDictationDevice(/* productId= */ 0x091a);
36 | expect(state.dictationDevice.getDeviceType())
37 | .toBe(DeviceType.FOOT_CONTROL_ACC_2330);
38 | expect(state.dictationDevice.implType)
39 | .toBe(ImplementationType.FOOT_CONTROL);
40 | });
41 | });
42 |
43 | it('handles input reports', async () => {
44 | await createDictationDevice(/* productId= */ 0x1844);
45 |
46 | const testCases: ButtonMappingTestCase[] = [
47 | {inputReportData: [0], expectedButtonEvents: undefined},
48 | {inputReportData: [1], expectedButtonEvents: ButtonEvent.REWIND},
49 | {inputReportData: [2], expectedButtonEvents: ButtonEvent.PLAY},
50 | {inputReportData: [4], expectedButtonEvents: ButtonEvent.FORWARD},
51 | {inputReportData: [8], expectedButtonEvents: ButtonEvent.EOL_PRIO},
52 | {inputReportData: [16], expectedButtonEvents: undefined},
53 | ];
54 | const resetButtonInputReport = [0];
55 | await checkButtonMapping(
56 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
57 | testCases, resetButtonInputReport);
58 | });
59 | });
60 |
--------------------------------------------------------------------------------
/src/test_util/check_button_mapping.ts:
--------------------------------------------------------------------------------
1 | import {ButtonEvent, DictationDeviceBase} from '../dictation_device_base';
2 |
3 | import {FakeHidDevice} from './fake_hid_device';
4 |
5 | export type ButtonMappingTestCase = {
6 | inputReportData: number[],
7 | expectedButtonEvents: ButtonEvent|undefined,
8 | }
9 |
10 | /*
11 | Runs through all `testCases` and emulates the respective `inputReportData`
12 | being sent, followed by the `resetInputReport` being sent. If
13 | `expectedButtonEvents` (can be a combination of ButtonEvents) has a value, it
14 | expects the `buttonEventListener` to be called with `expectedDevice` and
15 | `expectedButtonEvents` when the test cases's input report is being sent and
16 | then a ButtonEvent.NONE when the `resetInputCommand` (=emulating releasing
17 | the button) is being sent. If `expectedButtonEvents` is undefined, no button
18 | event should be emitted for the test case's `inputReportData` or the
19 | `resetInputReport`.
20 | */
21 | export async function checkButtonMapping(
22 | fakeHidDevice: FakeHidDevice, expectedDicationDevice: DictationDeviceBase,
23 | buttonEventListener: jasmine.Spy, testCases: ButtonMappingTestCase[],
24 | resetInputReport: number[]|undefined) {
25 | for (let i = 0; i < testCases.length; ++i) {
26 | const testCase = testCases[i];
27 | // Press button(s)
28 | buttonEventListener.calls.reset();
29 | await fakeHidDevice.handleInputReport(testCase.inputReportData);
30 | const contextMessageButtonPress =
31 | `for test case ${i} (inputReport [${testCase.inputReportData})]`
32 | if (testCase.expectedButtonEvents !== undefined) {
33 | expect(buttonEventListener)
34 | .withContext(contextMessageButtonPress)
35 | .toHaveBeenCalledOnceWith(
36 | expectedDicationDevice, testCase.expectedButtonEvents);
37 | }
38 | else {
39 | expect(buttonEventListener)
40 | .withContext(contextMessageButtonPress)
41 | .not.toHaveBeenCalled();
42 | }
43 |
44 | if (resetInputReport === undefined) continue;
45 |
46 | // Release button(s)
47 | buttonEventListener.calls.reset();
48 | await fakeHidDevice.handleInputReport(resetInputReport);
49 | const contextMessageButtonRelease = `for test case ${i} (inputReport [${
50 | resetInputReport} after ${testCase.inputReportData})]`
51 | if (testCase.expectedButtonEvents !== undefined) {
52 | expect(buttonEventListener)
53 | .withContext(contextMessageButtonRelease)
54 | .toHaveBeenCalledOnceWith(expectedDicationDevice, ButtonEvent.NONE);
55 | }
56 | else {
57 | expect(buttonEventListener)
58 | .withContext(contextMessageButtonRelease)
59 | .not.toHaveBeenCalled();
60 | }
61 | }
62 | }
63 |
--------------------------------------------------------------------------------
/src/test_util/fake_hid_device.ts:
--------------------------------------------------------------------------------
1 | /* eslint-disable @typescript-eslint/no-explicit-any */
2 | type InputReportListener = (event: HIDInputReportEvent) => any;
3 | type SendReportReceiver = (reportId: number, data: BufferSource) => void;
4 |
5 | export class FakeHidDevice implements HIDDevice {
6 | /* eslint-disable @typescript-eslint/no-explicit-any */
7 | oninputreport: ((this: HIDDevice, ev: HIDInputReportEvent) => any)|null;
8 | opened = false;
9 | readonly vendorId: number;
10 | readonly productId: number;
11 | readonly productName = 'product-name';
12 | readonly collections: HIDCollectionInfo[];
13 |
14 | protected readonly inputReportListeners = new Set();
15 | protected readonly sendReportReceiver?: SendReportReceiver;
16 |
17 | constructor(properties: {
18 | vendorId: number,
19 | productId: number,
20 | collections?: HIDCollectionInfo[],
21 | sendReportReceiver?: SendReportReceiver,
22 | }) {
23 | this.vendorId = properties.vendorId;
24 | this.productId = properties.productId;
25 | this.collections = properties.collections || [];
26 | this.sendReportReceiver = properties.sendReportReceiver;
27 | }
28 |
29 | async open() {
30 | if (this.opened) {
31 | throw new Error('device is already opened');
32 | }
33 |
34 | this.opened = true;
35 | }
36 |
37 | async close() {
38 | if (!this.opened) {
39 | throw new Error('device is already closed');
40 | }
41 |
42 | this.opened = false;
43 | }
44 |
45 | async forget() {
46 | throw new Error('Not implemented');
47 | }
48 |
49 | async sendReport(reportId: number, data: BufferSource) {
50 | if (reportId !== 0) {
51 | throw new Error(`Unexpected reportId ${reportId}`);
52 | }
53 |
54 | if (this.sendReportReceiver !== undefined) {
55 | this.sendReportReceiver(reportId, data);
56 | }
57 | }
58 |
59 | async sendFeatureReport(_reportId: number, _data: BufferSource) {
60 | throw new Error('Not implemented');
61 | }
62 |
63 | async receiveFeatureReport(_reportId: number): Promise {
64 | throw new Error('Not implemented');
65 | }
66 |
67 | dispatchEvent(_event: Event): boolean {
68 | throw new Error('Not implemented');
69 | }
70 |
71 | addEventListener(type: string, listener: InputReportListener) {
72 | if (type === 'inputreport') this.inputReportListeners.add(listener);
73 | }
74 |
75 | removeEventListener(type: string, listener: InputReportListener) {
76 | if (type === 'inputreport') this.inputReportListeners.delete(listener);
77 | }
78 |
79 | async handleInputReport(data: number[]) {
80 | const dataView = new DataView(new Uint8Array(data).buffer);
81 | const event: HIDInputReportEvent = {data: dataView} as unknown as
82 | HIDInputReportEvent;
83 | await Promise.all(
84 | [...this.inputReportListeners].map(listener => listener(event)));
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/src/speechmike_gamepad_device.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {ButtonEvent, DeviceType, DictationDeviceBase, ImplementationType,} from './dictation_device_base';
19 |
20 | const BUTTON_MAPPINGS_SPEECHMIKE = new Map([
21 | [ButtonEvent.REWIND, 1 << 0],
22 | [ButtonEvent.PLAY, 1 << 1],
23 | [ButtonEvent.FORWARD, 1 << 2],
24 | [ButtonEvent.INS_OVR, 1 << 4],
25 | [ButtonEvent.RECORD, 1 << 5],
26 | [ButtonEvent.COMMAND, 1 << 6],
27 | [ButtonEvent.INSTR, 1 << 9],
28 | [ButtonEvent.F1_A, 1 << 10],
29 | [ButtonEvent.F2_B, 1 << 11],
30 | [ButtonEvent.F3_C, 1 << 12],
31 | [ButtonEvent.F4_D, 1 << 13],
32 | [ButtonEvent.EOL_PRIO, 1 << 14],
33 | ]);
34 |
35 | const BUTTON_MAPPINGS_POWERMIC_4 = new Map([
36 | [ButtonEvent.TAB_BACKWARD, 1 << 0],
37 | [ButtonEvent.PLAY, 1 << 1],
38 | [ButtonEvent.TAB_FORWARD, 1 << 2],
39 | [ButtonEvent.FORWARD, 1 << 4],
40 | [ButtonEvent.RECORD, 1 << 5],
41 | [ButtonEvent.COMMAND, 1 << 6],
42 | [ButtonEvent.ENTER_SELECT, 1 << 9],
43 | [ButtonEvent.F1_A, 1 << 10],
44 | [ButtonEvent.F2_B, 1 << 11],
45 | [ButtonEvent.F3_C, 1 << 12],
46 | [ButtonEvent.F4_D, 1 << 13],
47 | [ButtonEvent.REWIND, 1 << 14],
48 | ]);
49 |
50 | export class SpeechMikeGamepadDevice extends DictationDeviceBase {
51 | readonly implType = ImplementationType.SPEECHMIKE_GAMEPAD;
52 |
53 | static create(hidDevice: HIDDevice) {
54 | return new SpeechMikeGamepadDevice(hidDevice);
55 | }
56 |
57 | getDeviceType(): DeviceType {
58 | // All SpeechMikes have the same productId (except PowerMic IV) and the lfh
59 | // is only available on the SpeechMikeHidDevice. Since this device is only
60 | // used as proxy within a SpeechMikeHidDevice, we don't really care about
61 | // the type here.
62 | return DeviceType.UNKNOWN;
63 | }
64 |
65 | protected getButtonMappings(): Map {
66 | if (this.hidDevice.vendorId === 0x0554 &&
67 | this.hidDevice.productId === 0x0064) {
68 | return BUTTON_MAPPINGS_POWERMIC_4;
69 | }
70 | return BUTTON_MAPPINGS_SPEECHMIKE;
71 | }
72 |
73 | protected getInputBitmask(data: DataView): number {
74 | return data.getUint16(0, /* littleEndian= */ true);
75 | }
76 |
77 | protected getThisAsDictationDevice(): SpeechMikeGamepadDevice {
78 | return this;
79 | }
80 | }
81 |
--------------------------------------------------------------------------------
/src/test_util/fake_hid_api.ts:
--------------------------------------------------------------------------------
1 | import {FakeHidDevice} from './fake_hid_device';
2 |
3 | type DeviceEventListener = (event: HIDConnectionEvent) => void|Promise;
4 |
5 | export class FakeHidApi implements HID {
6 | /* eslint-disable @typescript-eslint/no-explicit-any */
7 | onconnect: ((this: this, ev: Event) => any)|null = null;
8 | /* eslint-disable @typescript-eslint/no-explicit-any */
9 | ondisconnect: ((this: this, ev: Event) => any)|null = null;
10 |
11 | protected readonly connectListeners = new Set();
12 | protected readonly disconnectListeners = new Set();
13 |
14 | // `devices` are the devices already available via API (i.e. permission
15 | // already granted via requestDevice() or admin-policy), while
16 | // `pendingDevices` contains connected devices that haven't been granted
17 | // access to yet (requires call of `requestDevice()`).
18 | protected readonly devices = new Set();
19 | protected readonly pendingDevices = new Set();
20 |
21 | getDevices(): Promise {
22 | return Promise.resolve(Array.from(this.devices));
23 | }
24 |
25 | requestDevice(_options?: HIDDeviceRequestOptions): Promise {
26 | // We ignore the filters in options here. Checking them is done by spying on
27 | // requestDevice(). We simply move all pending devices to added devices.
28 | const result: FakeHidDevice[] = [];
29 | for (const pendingDevice of Array.from(this.pendingDevices)) {
30 | this.devices.add(pendingDevice);
31 | result.push(pendingDevice);
32 | }
33 | this.pendingDevices.clear();
34 | return Promise.resolve(result);
35 | }
36 |
37 | addEventListener(
38 | type: 'connect'|'disconnect', listener: DeviceEventListener) {
39 | if (type === 'connect') {
40 | this.connectListeners.add(listener);
41 | } else {
42 | this.disconnectListeners.add(listener);
43 | }
44 | }
45 |
46 | removeEventListener(
47 | type: 'connect'|'disconnect', listener: DeviceEventListener) {
48 | if (type === 'connect') {
49 | this.connectListeners.delete(listener);
50 | } else {
51 | this.disconnectListeners.delete(listener);
52 | }
53 | }
54 |
55 | dispatchEvent(_event: Event): boolean {
56 | throw new Error('Not implemented');
57 | }
58 |
59 | async connectDevice(device: FakeHidDevice, isPending = false) {
60 | if (isPending) {
61 | this.pendingDevices.add(device);
62 | } else {
63 | this.devices.add(device);
64 | const event: HIDConnectionEvent = {device} as unknown as
65 | HIDConnectionEvent;
66 | await Promise.all(
67 | [...this.connectListeners].map(listener => listener(event)));
68 | }
69 | }
70 |
71 | async disconnectDevice(device: FakeHidDevice) {
72 | this.pendingDevices.delete(device);
73 | const wasAdded = this.devices.delete(device);
74 | if (!wasAdded) return;
75 | const event: HIDConnectionEvent = {device} as unknown as HIDConnectionEvent;
76 | await Promise.all(
77 | [...this.disconnectListeners].map(listener => listener(event)));
78 | }
79 | }
80 |
--------------------------------------------------------------------------------
/src/powermic_3_device_test.ts:
--------------------------------------------------------------------------------
1 | import {ButtonEvent, DeviceType, ImplementationType} from './dictation_device_base';
2 | import {LedStatePM3, PowerMic3Device} from './powermic_3_device';
3 | import {ButtonMappingTestCase, checkButtonMapping} from './test_util/check_button_mapping';
4 | import {cleanState} from './test_util/clean_state';
5 | import {FakeHidDevice} from './test_util/fake_hid_device';
6 |
7 | describe('PowerMic3Device', () => {
8 | const state = cleanState<{
9 | buttonEventListener: jasmine.Spy,
10 | dictationDevice: PowerMic3Device,
11 | fakeHidDevice: FakeHidDevice,
12 | sendReportReceiver: jasmine.Spy,
13 | }>();
14 |
15 | beforeEach(async () => {
16 | state.buttonEventListener = jasmine.createSpy('buttonEventListener');
17 | state.sendReportReceiver = jasmine.createSpy('sendReportReceiver');
18 |
19 | state.fakeHidDevice = new FakeHidDevice({
20 | productId: 0x1001,
21 | vendorId: 0x0554,
22 | sendReportReceiver: state.sendReportReceiver
23 | });
24 | state.dictationDevice = PowerMic3Device.create(state.fakeHidDevice);
25 | state.dictationDevice.addButtonEventListener(state.buttonEventListener);
26 | await state.dictationDevice.init();
27 | });
28 |
29 | it('creates the right device type', async () => {
30 | expect(state.dictationDevice.getDeviceType()).toBe(DeviceType.POWERMIC_3);
31 | expect(state.dictationDevice.implType).toBe(ImplementationType.POWERMIC_3);
32 | });
33 |
34 | it('handles input reports', async () => {
35 | const testCases: ButtonMappingTestCase[] = [
36 | {
37 | inputReportData: [0, 0, 0],
38 | expectedButtonEvents: undefined,
39 | },
40 | {
41 | inputReportData: [0, 1, 0],
42 | expectedButtonEvents: ButtonEvent.TRANSCRIBE
43 | },
44 | {
45 | inputReportData: [0, 2, 0],
46 | expectedButtonEvents: ButtonEvent.TAB_BACKWARD
47 | },
48 | {
49 | inputReportData: [0, 4, 0],
50 | expectedButtonEvents: ButtonEvent.RECORD,
51 | },
52 | {
53 | inputReportData: [0, 8, 0],
54 | expectedButtonEvents: ButtonEvent.TAB_FORWARD
55 | },
56 | {
57 | inputReportData: [0, 16, 0],
58 | expectedButtonEvents: ButtonEvent.REWIND,
59 | },
60 | {
61 | inputReportData: [0, 32, 0],
62 | expectedButtonEvents: ButtonEvent.FORWARD,
63 | },
64 | {
65 | inputReportData: [0, 64, 0],
66 | expectedButtonEvents: ButtonEvent.PLAY,
67 | },
68 | {
69 | inputReportData: [0, 128, 0],
70 | expectedButtonEvents: ButtonEvent.CUSTOM_LEFT
71 | },
72 | {
73 | inputReportData: [0, 0, 1],
74 | expectedButtonEvents: ButtonEvent.ENTER_SELECT
75 | },
76 | {
77 | inputReportData: [0, 0, 2],
78 | expectedButtonEvents: ButtonEvent.CUSTOM_RIGHT
79 | },
80 | {
81 | inputReportData: [0, 0, 4],
82 | expectedButtonEvents: undefined,
83 | },
84 | ];
85 | const resetButtonInputReport = [0, 0, 0];
86 | await checkButtonMapping(
87 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
88 | testCases, resetButtonInputReport);
89 | });
90 |
91 | it('sends commands to set LEDs', async () => {
92 | expect(state.sendReportReceiver).not.toHaveBeenCalled();
93 |
94 | // OFF
95 | await state.dictationDevice.setLed(LedStatePM3.OFF);
96 | expect(state.sendReportReceiver)
97 | .toHaveBeenCalledOnceWith(/* reportId= */ 0, new Uint8Array([0]));
98 | state.sendReportReceiver.calls.reset();
99 |
100 | // RED
101 | await state.dictationDevice.setLed(LedStatePM3.RED);
102 | expect(state.sendReportReceiver)
103 | .toHaveBeenCalledOnceWith(/* reportId= */ 0, new Uint8Array([1]));
104 | state.sendReportReceiver.calls.reset();
105 |
106 | // GREEN
107 | await state.dictationDevice.setLed(LedStatePM3.GREEN);
108 | expect(state.sendReportReceiver)
109 | .toHaveBeenCalledOnceWith(/* reportId= */ 0, new Uint8Array([2]));
110 | state.sendReportReceiver.calls.reset();
111 | });
112 | });
113 |
--------------------------------------------------------------------------------
/src/dictation_device_base_test.ts:
--------------------------------------------------------------------------------
1 | import {ButtonEvent, DeviceType, DictationDeviceBase, ImplementationType} from './dictation_device_base';
2 | import {FootControlDevice} from './foot_control_device';
3 | import {ButtonMappingTestCase, checkButtonMapping} from './test_util/check_button_mapping';
4 | import {cleanState} from './test_util/clean_state';
5 | import {FakeHidDevice} from './test_util/fake_hid_device';
6 |
7 | const BUTTON_MAPPINGS = new Map([
8 | [ButtonEvent.PLAY, 1 << 0],
9 | [ButtonEvent.RECORD, 1 << 1],
10 | ]);
11 |
12 | class TestDictationDevice extends DictationDeviceBase {
13 | readonly implType = ImplementationType.FOOT_CONTROL;
14 |
15 | static create(hidDevice: HIDDevice) {
16 | return new TestDictationDevice(hidDevice);
17 | }
18 |
19 | getDeviceType(): DeviceType {
20 | return DeviceType.UNKNOWN;
21 | }
22 |
23 | protected getButtonMappings(): Map {
24 | return BUTTON_MAPPINGS;
25 | }
26 |
27 | protected getInputBitmask(data: DataView): number {
28 | return data.getUint8(0);
29 | }
30 |
31 | protected getThisAsDictationDevice(): FootControlDevice {
32 | // Workaround since TestDictationDevice is actually not a DictationDevice.
33 | return this as unknown as FootControlDevice;
34 | }
35 | }
36 |
37 | describe('DictationDeviceBase', () => {
38 | const state = cleanState<{
39 | buttonEventListener: jasmine.Spy,
40 | dictationDevice: TestDictationDevice,
41 | fakeHidDevice: FakeHidDevice,
42 | }>();
43 |
44 | beforeEach(() => {
45 | state.buttonEventListener = jasmine.createSpy('buttonEventListener');
46 |
47 | state.fakeHidDevice = new FakeHidDevice({productId: 123, vendorId: 456});
48 | state.dictationDevice = TestDictationDevice.create(state.fakeHidDevice);
49 | state.dictationDevice.addButtonEventListener(state.buttonEventListener);
50 | });
51 |
52 | describe('init()', () => {
53 | it('opens the device if closed', async () => {
54 | expect(state.fakeHidDevice.opened).toBe(false);
55 |
56 | await state.dictationDevice.init();
57 | expect(state.fakeHidDevice.opened).toBe(true);
58 | });
59 |
60 | it('does not open the device if already opened', async () => {
61 | await state.fakeHidDevice.open();
62 | expect(state.fakeHidDevice.opened).toBe(true);
63 |
64 | await state.dictationDevice.init();
65 | expect(state.fakeHidDevice.opened).toBe(true);
66 | });
67 | });
68 |
69 | describe('shutdown()', () => {
70 | beforeEach(async () => {
71 | await state.dictationDevice.init();
72 | expect(state.fakeHidDevice.opened).toBe(true);
73 | });
74 |
75 | it('closes the device if requested', async () => {
76 | await state.dictationDevice.shutdown(/*closeDevice=*/ true);
77 | expect(state.fakeHidDevice.opened).toBe(false);
78 | });
79 |
80 | it('does not close the device if not requested', async () => {
81 | await state.dictationDevice.shutdown(/*closeDevice=*/ false);
82 | expect(state.fakeHidDevice.opened).toBe(true);
83 | });
84 | });
85 |
86 | describe('handles input reports', () => {
87 | beforeEach(async () => {
88 | await state.dictationDevice.init();
89 | });
90 |
91 | it('does not fire for unknown input reports', async () => {
92 | await state.fakeHidDevice.handleInputReport([0]);
93 | expect(state.buttonEventListener).not.toHaveBeenCalled();
94 | });
95 |
96 | it('simple button mapping', async () => {
97 | const testCases: ButtonMappingTestCase[] = [
98 | // Single button presses
99 | {inputReportData: [1], expectedButtonEvents: ButtonEvent.PLAY},
100 | {inputReportData: [2], expectedButtonEvents: ButtonEvent.RECORD},
101 | // Multiple buttons at the same time
102 | {
103 | inputReportData: [3],
104 | expectedButtonEvents: ButtonEvent.PLAY | ButtonEvent.RECORD
105 | },
106 | ];
107 | const resetButtonInputReport = [0];
108 | await checkButtonMapping(
109 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
110 | testCases, resetButtonInputReport);
111 | });
112 |
113 | it('does not fire event twice if unchanged', async () => {
114 | // PLAY
115 | await state.fakeHidDevice.handleInputReport(
116 | [/*PLAY=*/ 1, /*unrelatedData=*/ 0, 0, 0, 0]);
117 | expect(state.buttonEventListener)
118 | .toHaveBeenCalledOnceWith(state.dictationDevice, ButtonEvent.PLAY);
119 | state.buttonEventListener.calls.reset();
120 |
121 | // Does not fire again for same buttons
122 | await state.fakeHidDevice.handleInputReport(
123 | [/*PLAY=*/ 1, /*unrelatedData=*/ 1, 2, 3, 4]);
124 | expect(state.buttonEventListener).not.toHaveBeenCalled();
125 | });
126 | });
127 | });
128 |
--------------------------------------------------------------------------------
/src/dictation_device_base.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {DictationDevice} from './dictation_device';
19 |
20 | export enum ImplementationType {
21 | SPEECHMIKE_HID = 0,
22 | SPEECHMIKE_GAMEPAD = 1,
23 | FOOT_CONTROL = 2,
24 | POWERMIC_3 = 3,
25 | }
26 |
27 | export enum DeviceType {
28 | UNKNOWN = 0,
29 | FOOT_CONTROL_ACC_2310_2320 = 6212,
30 | FOOT_CONTROL_ACC_2330 = 2330,
31 | SPEECHMIKE_LFH_3200 = 3200,
32 | SPEECHMIKE_LFH_3210 = 3210,
33 | SPEECHMIKE_LFH_3220 = 3220,
34 | SPEECHMIKE_LFH_3300 = 3300,
35 | SPEECHMIKE_LFH_3310 = 3310,
36 | SPEECHMIKE_LFH_3500 = 3500,
37 | SPEECHMIKE_LFH_3510 = 3510,
38 | SPEECHMIKE_LFH_3520 = 3520,
39 | SPEECHMIKE_LFH_3600 = 3600,
40 | SPEECHMIKE_LFH_3610 = 3610,
41 | SPEECHMIKE_SMP_3700 = 3700,
42 | SPEECHMIKE_SMP_3710 = 3710,
43 | SPEECHMIKE_SMP_3720 = 3720,
44 | SPEECHMIKE_SMP_3800 = 3800,
45 | SPEECHMIKE_SMP_3810 = 3810,
46 | SPEECHMIKE_SMP_4000 = 4000,
47 | SPEECHMIKE_SMP_4010 = 4010,
48 | SPEECHONE_PSM_6000 = 6001,
49 | POWERMIC_3 = 4097,
50 | POWERMIC_4 = 100,
51 | SPEECHMIKE_AMBIENT_PSM5000 = 5000,
52 | }
53 |
54 | export enum ButtonEvent {
55 | NONE = 0,
56 | REWIND = 1 << 0,
57 | PLAY = 1 << 1,
58 | FORWARD = 1 << 2,
59 | INS_OVR = 1 << 4,
60 | RECORD = 1 << 5,
61 | COMMAND = 1 << 6,
62 | STOP = 1 << 8,
63 | INSTR = 1 << 9,
64 | F1_A = 1 << 10,
65 | F2_B = 1 << 11,
66 | F3_C = 1 << 12,
67 | F4_D = 1 << 13,
68 | EOL_PRIO = 1 << 14,
69 | TRANSCRIBE = 1 << 15,
70 | TAB_BACKWARD = 1 << 16,
71 | TAB_FORWARD = 1 << 17,
72 | CUSTOM_LEFT = 1 << 18,
73 | CUSTOM_RIGHT = 1 << 19,
74 | ENTER_SELECT = 1 << 20,
75 | SCAN_END = 1 << 21,
76 | SCAN_SUCCESS = 1 << 22,
77 | }
78 |
79 | export type ButtonEventListener =
80 | (device: DictationDevice, bitMask: ButtonEvent) => void|Promise;
81 |
82 | export abstract class DictationDeviceBase {
83 | private static next_id = 0;
84 |
85 | readonly id = DictationDeviceBase.next_id++;
86 | abstract readonly implType: ImplementationType;
87 |
88 | protected readonly buttonEventListeners = new Set();
89 | protected lastBitMask = 0;
90 |
91 | protected readonly onInputReportHandler = (event: HIDInputReportEvent) =>
92 | this.onInputReport(event);
93 |
94 | protected constructor(readonly hidDevice: HIDDevice) {}
95 |
96 | async init() {
97 | this.hidDevice.addEventListener('inputreport', this.onInputReportHandler);
98 |
99 | if (this.hidDevice.opened === false) {
100 | await this.hidDevice.open();
101 | }
102 | }
103 |
104 | async shutdown(closeDevice = true) {
105 | this.hidDevice.removeEventListener(
106 | 'inputreport', this.onInputReportHandler);
107 |
108 | if (closeDevice) {
109 | await this.hidDevice.close();
110 | }
111 |
112 | this.buttonEventListeners.clear();
113 | }
114 |
115 | addButtonEventListener(listener: ButtonEventListener) {
116 | this.buttonEventListeners.add(listener);
117 | }
118 |
119 | protected async onInputReport(event: HIDInputReportEvent) {
120 | const data = event.data;
121 | await this.handleButtonPress(data);
122 | }
123 |
124 | protected async handleButtonPress(data: DataView) {
125 | const buttonMappings = this.getButtonMappings();
126 | const inputBitMask = this.getInputBitmask(data);
127 | let outputBitMask = 0;
128 | for (const [buttonEvent, buttonMapping] of buttonMappings) {
129 | if (inputBitMask & buttonMapping) outputBitMask |= buttonEvent;
130 | }
131 |
132 | if (outputBitMask === this.lastBitMask) return;
133 | this.lastBitMask = outputBitMask;
134 |
135 | outputBitMask = this.filterOutputBitMask(outputBitMask);
136 |
137 | await Promise.all([...this.buttonEventListeners].map(
138 | listener => listener(this.getThisAsDictationDevice(), outputBitMask)));
139 | }
140 |
141 | protected filterOutputBitMask(outputBitMask: number): number {
142 | return outputBitMask; // default = no filtering
143 | }
144 |
145 | abstract getDeviceType(): DeviceType;
146 | protected abstract getButtonMappings(): Map;
147 | protected abstract getInputBitmask(data: DataView): number;
148 | protected abstract getThisAsDictationDevice(): DictationDevice;
149 | }
150 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Dictation support SDK
2 |
3 | ## Features
4 | The SDK provided with this repository allows web-based apps/pages to interact with dictation devices:
5 | * Events for connected / disconnected devices
6 | * Events for button presses / slider movements
7 | * Events for motion events (pickedup and layed down)
8 | * Commands to set LED states (predefined states or individual LED settings)
9 |
10 | ### Demo website
11 | You can test out the SDK capabilities with a supported device using this [demo website](https://storage.googleapis.com/chromeos-mgmt-public-extension/dictation_support/index.html).
12 |
13 | TODO: host latest release and link here
14 |
15 | ### Supported devices
16 | | Dictation microphones | Foot controls |
17 | |:-----------------------------------------|:----------------|
18 | | Philips SpeechMike Premium LFH3500 | Philips ACC2310 |
19 | | Philips SpeechMike Premium LFH3510 | Philips ACC2320 |
20 | | Philips SpeechMike Premium LFH3520 | Philips ACC2330 |
21 | | Philips SpeechMike Premium LFH3600 | |
22 | | Philips SpeechMike Premium LFH3610 | |
23 | | Philips SpeechMike Premium Touch SMP3700 | |
24 | | Philips SpeechMike Premium Touch SMP3710 | |
25 | | Philips SpeechMike Premium Touch SMP3720 | |
26 | | Philips SpeechMike Premium Touch SMP3800 | |
27 | | Philips SpeechMike Premium Touch SMP3810 | |
28 | | Philips SpeechMike Premium Air SMP4000 | |
29 | | Philips SpeechMike Premium Air SMP4010 | |
30 | | Philips SpeechOne PSM6000 | |
31 | | Philips SpeechMike Ambient PSM5000 | |
32 | | Nuance PowerMic III | |
33 | | Nuance PowerMic 4 | |
34 |
35 | ### Supported platforms
36 | * Google Chrome on Windows, macOS, Linux and Chrome OS (Chromebooks)
37 | * Microsoft Edge on Windows and macOS
38 |
39 | #### Note
40 | If your browser runs remotely (e.g. Citrix Virtual Apps and Desktops, VMware Horizon...) you need to have the dictation device buttons redirected on USB level. Please follow documentation provided by the virtualization platform vendors.
41 |
42 | ## How to use
43 |
44 | ### Sample usage
45 |
46 | #### NPM package
47 | [Link to package](https://www.npmjs.com/package/dictation_support)
48 |
49 | Run `npm install dictation_support --save-dev` to install the package as dependency and then use as
50 |
51 | ```typescript
52 | import { DictationDevice, ButtonEvent, DictationDeviceManager } from 'dictation_support';
53 |
54 | const manager = new DictationDeviceManager();
55 | // Your code here
56 | ```
57 |
58 | #### Include compiled sdk
59 | To use the SDK, simply include the compiled `index.js` into your web page/app and create an instance of `DictationSupport.DictationDeviceManager`. See [/example/index.ejs](https://github.com/GoogleChromeLabs/dictation_support/blob/main/example/index.ejs) or the resulting `/dist/index.html` for an example.
60 |
61 | ### WebHID permission / policy
62 | The SDK requires permission to interact with the device using the [WebHID API](https://wicg.github.io/webhid/). This can happen two different ways:
63 |
64 | #### User grants permission
65 | You can use `await deviceManager.requestDevice()`, which will prompt the user to select one of the supported devices from a pop-up. Once the user has granted permission, the device will be available, i.e. a new `DictationDevice` will be created. That device will also be available via `deviceManger.getDevices()` when the page reloads. Disconnecting and reconnecting the device will require the user to grant permission again using `deviceManager.requestDevice()`.
66 |
67 | #### Admin grants permission
68 | TODO(Google): surface [WebHidAllowAllDevicesForUrls](https://chromeenterprise.google/policies/#WebHidAllowAllDevicesForUrls) to the admin console (ETA: 2022Q4)
69 |
70 | Instead of the user being prompted to grant permissions, the admin can also grant permissions upfront.
71 |
72 | On the [Google admin console](https://admin.google.com), navigate to the user or managed guest session policy page and search for `WebHidAllowAllDevicesForUrls`. With this setting, you can allowlist certain devices (vendor ID & product ID) to the URLs you want to use the SDK on.
73 | Note: The Philips SpeechMikes have different product IDs depending on the event mode (HID vs browser/gamepad mode), see [/src/device_manager.ts](https://github.com/GoogleChromeLabs/dictation_support/blob/main/src/dictation_device_manager.ts) for a list of supported product and vendor IDs (in hex format) in various modes. The product and vendor ID for the policy have to be provided in decimal representation.
74 |
75 | If the device is granted permission via policy, the device will be available to the SDK immediately when it is connected (also firing an event).
76 |
77 | ## Developer instructions
78 |
79 | ## Installation
80 | In order to get started developing, run `npm install` to install the required dependencies.
81 |
82 | ## Build
83 | To build the SDK, run `npm run build`, which will create the following set of files
84 | * `/dist/index.js` the SDK you need to include
85 | * `/dist/index.d.ts` Typescript typings for the SDK
86 | * `/dist/index.html` sample page using the SDK
87 |
88 | ## Contributing
89 | Feel free to send pull-requests! All code changes must be:
90 | * approved by a project maintainer
91 | * pass linting (use `npm run lint`)
92 | * be properly formatted (use `npm run format` or `npm run formatCheck`)
93 | * pass tests (use `npm test`)
94 |
--------------------------------------------------------------------------------
/src/speechmike_gamepad_device_test.ts:
--------------------------------------------------------------------------------
1 | import {ButtonEvent, DeviceType, ImplementationType} from './dictation_device_base';
2 | import {SpeechMikeGamepadDevice} from './speechmike_gamepad_device';
3 | import {ButtonMappingTestCase, checkButtonMapping} from './test_util/check_button_mapping';
4 | import {cleanState} from './test_util/clean_state';
5 | import {FakeHidDevice} from './test_util/fake_hid_device';
6 |
7 | describe('SpeechMikeGamepadDevice', () => {
8 | const state = cleanState<{
9 | buttonEventListener: jasmine.Spy,
10 | dictationDevice: SpeechMikeGamepadDevice,
11 | fakeHidDevice: FakeHidDevice,
12 | }>();
13 |
14 | beforeEach(() => {
15 | state.buttonEventListener = jasmine.createSpy('buttonEventListener');
16 | });
17 |
18 | async function createDictationDevice(
19 | properties: {vendorId: number, productId: number}) {
20 | state.fakeHidDevice = new FakeHidDevice(properties);
21 | state.dictationDevice = SpeechMikeGamepadDevice.create(state.fakeHidDevice);
22 | state.dictationDevice.addButtonEventListener(state.buttonEventListener);
23 | await state.dictationDevice.init();
24 | }
25 |
26 | describe('creates the right device type', () => {
27 | it('SpeechMikes', async () => {
28 | await createDictationDevice({vendorId: 0x0911, productId: 0x0fa0});
29 | expect(state.dictationDevice.getDeviceType()).toBe(DeviceType.UNKNOWN);
30 | expect(state.dictationDevice.implType)
31 | .toBe(ImplementationType.SPEECHMIKE_GAMEPAD);
32 | });
33 |
34 | it('PowerMic4', async () => {
35 | await createDictationDevice({vendorId: 0x0554, productId: 0x0064});
36 | expect(state.dictationDevice.getDeviceType()).toBe(DeviceType.UNKNOWN);
37 | expect(state.dictationDevice.implType)
38 | .toBe(ImplementationType.SPEECHMIKE_GAMEPAD);
39 | });
40 | });
41 |
42 | describe('handles input reports', () => {
43 | it('SpeechMikes', async () => {
44 | await createDictationDevice({vendorId: 0x0911, productId: 0x0fa0});
45 |
46 | const testCases: ButtonMappingTestCase[] = [
47 | {inputReportData: [0, 0], expectedButtonEvents: undefined},
48 | {inputReportData: [1, 0], expectedButtonEvents: ButtonEvent.REWIND},
49 | {inputReportData: [2, 0], expectedButtonEvents: ButtonEvent.PLAY},
50 | {inputReportData: [4, 0], expectedButtonEvents: ButtonEvent.FORWARD},
51 | {inputReportData: [8, 0], expectedButtonEvents: undefined},
52 | {inputReportData: [16, 0], expectedButtonEvents: ButtonEvent.INS_OVR},
53 | {inputReportData: [32, 0], expectedButtonEvents: ButtonEvent.RECORD},
54 | {inputReportData: [64, 0], expectedButtonEvents: ButtonEvent.COMMAND},
55 | {inputReportData: [128, 0], expectedButtonEvents: undefined},
56 | {inputReportData: [0, 1], expectedButtonEvents: undefined},
57 | {inputReportData: [0, 2], expectedButtonEvents: ButtonEvent.INSTR},
58 | {inputReportData: [0, 4], expectedButtonEvents: ButtonEvent.F1_A},
59 | {inputReportData: [0, 8], expectedButtonEvents: ButtonEvent.F2_B},
60 | {inputReportData: [0, 16], expectedButtonEvents: ButtonEvent.F3_C},
61 | {inputReportData: [0, 32], expectedButtonEvents: ButtonEvent.F4_D},
62 | {inputReportData: [0, 64], expectedButtonEvents: ButtonEvent.EOL_PRIO},
63 | {inputReportData: [0, 128], expectedButtonEvents: undefined},
64 | ];
65 | const resetButtonInputReport = [0, 0];
66 | await checkButtonMapping(
67 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
68 | testCases, resetButtonInputReport);
69 | });
70 |
71 | it('PowerMic4', async () => {
72 | await createDictationDevice({vendorId: 0x0554, productId: 0x0064});
73 |
74 | const testCases: ButtonMappingTestCase[] = [
75 | {inputReportData: [0, 0], expectedButtonEvents: undefined},
76 | {
77 | inputReportData: [1, 0],
78 | expectedButtonEvents: ButtonEvent.TAB_BACKWARD
79 | },
80 | {inputReportData: [2, 0], expectedButtonEvents: ButtonEvent.PLAY},
81 | {
82 | inputReportData: [4, 0],
83 | expectedButtonEvents: ButtonEvent.TAB_FORWARD
84 | },
85 | {inputReportData: [8, 0], expectedButtonEvents: undefined},
86 | {inputReportData: [16, 0], expectedButtonEvents: ButtonEvent.FORWARD},
87 | {inputReportData: [32, 0], expectedButtonEvents: ButtonEvent.RECORD},
88 | {inputReportData: [64, 0], expectedButtonEvents: ButtonEvent.COMMAND},
89 | {inputReportData: [128, 0], expectedButtonEvents: undefined},
90 | {inputReportData: [0, 1], expectedButtonEvents: undefined},
91 | {
92 | inputReportData: [0, 2],
93 | expectedButtonEvents: ButtonEvent.ENTER_SELECT
94 | },
95 | {inputReportData: [0, 4], expectedButtonEvents: ButtonEvent.F1_A},
96 | {inputReportData: [0, 8], expectedButtonEvents: ButtonEvent.F2_B},
97 | {inputReportData: [0, 16], expectedButtonEvents: ButtonEvent.F3_C},
98 | {inputReportData: [0, 32], expectedButtonEvents: ButtonEvent.F4_D},
99 | {inputReportData: [0, 64], expectedButtonEvents: ButtonEvent.REWIND},
100 | {inputReportData: [0, 128], expectedButtonEvents: undefined},
101 | ];
102 | const resetButtonInputReport = [0, 0];
103 | await checkButtonMapping(
104 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
105 | testCases, resetButtonInputReport);
106 | });
107 | });
108 | });
109 |
--------------------------------------------------------------------------------
/example/index.ejs:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 | Dictation support demo
7 |
8 |
9 |
10 |
11 |
149 |
150 |
151 |
152 |
153 |
154 |
155 |
156 |
157 |
158 |
159 | deviceId for commands:
160 |
161 |
208 |
209 |
221 |
222 |
223 |
224 |
225 |
226 |
227 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 |
2 | Apache License
3 | Version 2.0, January 2004
4 | http://www.apache.org/licenses/
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright [yyyy] [name of copyright owner]
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
--------------------------------------------------------------------------------
/src/dictation_device_manager.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {DictationDevice} from './dictation_device';
19 | import {ButtonEventListener, ImplementationType} from './dictation_device_base';
20 | import {FootControlDevice} from './foot_control_device';
21 | import {PowerMic3Device} from './powermic_3_device';
22 | import {SpeechMikeGamepadDevice} from './speechmike_gamepad_device';
23 | import {MotionEventListener, SpeechMikeHidDevice,} from './speechmike_hid_device';
24 |
25 | type DeviceEventListener = (device: DictationDevice) => void|Promise;
26 |
27 | const DEVICE_FILTERS: Readonly<
28 | Record>> =
29 | Object.freeze({
30 | [ImplementationType.SPEECHMIKE_HID]: Object.freeze([
31 | // Wired SpeechMikes (LFH35xx, LFH36xx, SMP37xx, SMP38xx) in HID mode
32 | Object.freeze(
33 | {vendorId: 0x0911, productId: 0x0c1c, usagePage: 65440, usage: 1}),
34 | // SpeechMike Premium Air (SMP40xx) in HID mode
35 | Object.freeze(
36 | {vendorId: 0x0911, productId: 0x0c1d, usagePage: 65440, usage: 1}),
37 | // SpeechOne (PSM6000) or SpeechMike Ambient (PSM5000) in HID or
38 | // Browser/Gamepad mode
39 | Object.freeze(
40 | {vendorId: 0x0911, productId: 0x0c1e, usagePage: 65440, usage: 1}),
41 | // All SpeechMikes in Browser/Gamepad mode
42 | Object.freeze(
43 | {vendorId: 0x0911, productId: 0x0fa0, usagePage: 65440, usage: 1}),
44 | // PowerMic IV in HID or Browser/Gamepad mode
45 | Object.freeze(
46 | {vendorId: 0x0554, productId: 0x0064, usagePage: 65440, usage: 1}),
47 | ]),
48 | [ImplementationType.SPEECHMIKE_GAMEPAD]: Object.freeze([
49 | // All SpeechMikes in Browser/Gamepad mode
50 | Object.freeze(
51 | {vendorId: 0x0911, productId: 0x0fa0, usagePage: 1, usage: 4}),
52 | // SpeechOne (PSM6000) or SpeechMike Ambient (PSM5000) in
53 | // Browser/Gamepad mode
54 | Object.freeze(
55 | {vendorId: 0x0911, productId: 0x0c1e, usagePage: 1, usage: 4}),
56 | // PowerMic IV in Browser/Gamepad mode
57 | Object.freeze(
58 | {vendorId: 0x0554, productId: 0x0064, usagePage: 1, usage: 4}),
59 | ]),
60 | [ImplementationType.FOOT_CONTROL]: Object.freeze([
61 | // 3-pedal Foot control ACC2310/2320
62 | Object.freeze(
63 | {vendorId: 0x0911, productId: 0x1844, usagePage: 1, usage: 4}),
64 | // 4-pedal Foot control ACC2330
65 | Object.freeze(
66 | {vendorId: 0x0911, productId: 0x091a, usagePage: 1, usage: 4}),
67 | ]),
68 | [ImplementationType.POWERMIC_3]: Object.freeze([
69 | // PowerMic III
70 | Object.freeze(
71 | {vendorId: 0x0554, productId: 0x1001, usagePage: 1, usage: 0}),
72 | ]),
73 | });
74 |
75 | export class DictationDeviceManager {
76 | protected readonly buttonEventListeners = new Set();
77 | protected readonly deviceConnectEventListeners =
78 | new Set();
79 | protected readonly deviceDisconnectEventListeners =
80 | new Set();
81 | protected readonly motionEventListeners = new Set();
82 |
83 | protected readonly devices = new Map();
84 | protected readonly pendingProxyDevices =
85 | new Map();
86 |
87 | protected readonly onConnectHandler = (event: HIDConnectionEvent) =>
88 | this.onHidDeviceConnected(event);
89 | protected readonly onDisconectHandler = (event: HIDConnectionEvent) =>
90 | this.onHidDeviceDisconnected(event);
91 |
92 | protected isInitialized = false;
93 |
94 | constructor(protected readonly hidApi = navigator.hid) {
95 | if (this.hidApi === undefined) {
96 | throw new Error('WebHID is not available');
97 | }
98 | }
99 |
100 | getDevices(): DictationDevice[] {
101 | this.failIfNotInitialized();
102 | return [...this.devices.values()];
103 | }
104 |
105 | async init() {
106 | if (this.isInitialized) {
107 | throw new Error('DictationDeviceManager already initialized');
108 | }
109 |
110 | this.hidApi.addEventListener('connect', this.onConnectHandler);
111 | this.hidApi.addEventListener('disconnect', this.onDisconectHandler);
112 |
113 | const hidDevices = await this.hidApi.getDevices();
114 | await this.createAndAddInitializedDevices(hidDevices);
115 |
116 | this.isInitialized = true;
117 | }
118 |
119 | async shutdown() {
120 | this.failIfNotInitialized();
121 |
122 | this.hidApi.removeEventListener('connect', this.onConnectHandler);
123 | this.hidApi.removeEventListener('disconnect', this.onDisconectHandler);
124 |
125 | await Promise.all([
126 | [...this.devices.values()].map(
127 | device => device.shutdown(/*closeDevice=*/ true)),
128 | ]);
129 | this.devices.clear();
130 |
131 | this.isInitialized = false;
132 | }
133 |
134 | async requestDevice(): Promise> {
135 | this.failIfNotInitialized();
136 |
137 | const hidDevices = await this.hidApi.requestDevice({
138 | filters: getFilters(),
139 | });
140 |
141 | const devices = await this.createAndAddInitializedDevices(hidDevices);
142 | return devices;
143 | }
144 |
145 | addButtonEventListener(listener: ButtonEventListener) {
146 | this.buttonEventListeners.add(listener);
147 | }
148 |
149 | addDeviceConnectedEventListener(listener: DeviceEventListener) {
150 | this.deviceConnectEventListeners.add(listener);
151 | }
152 |
153 | addDeviceDisconnectedEventListener(listener: DeviceEventListener) {
154 | this.deviceDisconnectEventListeners.add(listener);
155 | }
156 |
157 | addMotionEventListener(listener: MotionEventListener) {
158 | this.motionEventListeners.add(listener);
159 | }
160 |
161 | protected failIfNotInitialized() {
162 | if (!this.isInitialized) {
163 | throw new Error('DictationDeviceManager not yet initialized');
164 | }
165 | }
166 |
167 | protected async createAndAddInitializedDevices(hidDevices: HIDDevice[]):
168 | Promise> {
169 | const devices = await Promise.all(
170 | hidDevices.map(hidDevice => this.createDevice(hidDevice)));
171 |
172 | const result: DictationDevice[] = [];
173 | for (const device of devices) {
174 | if (device === undefined) continue;
175 |
176 | try {
177 | await device.init();
178 | } catch (e: unknown) {
179 | await device.shutdown();
180 | console.error('failed to initialize device', e);
181 | continue;
182 | }
183 |
184 | // Handle proxy device
185 | if (device.implType === ImplementationType.SPEECHMIKE_GAMEPAD) {
186 | this.pendingProxyDevices.set(device.hidDevice, device);
187 | continue;
188 | }
189 |
190 | // Handle host device
191 | this.addListeners(device);
192 | this.devices.set(device.hidDevice, device);
193 | result.push(device);
194 | }
195 |
196 | this.assignPendingProxyDevices();
197 |
198 | return result;
199 | }
200 |
201 | protected async createDevice(hidDevice: HIDDevice):
202 | Promise {
203 | // Don't recreate devices for known devices.
204 | if (this.devices.has(hidDevice)) return undefined;
205 |
206 | const implType = getImplType(hidDevice);
207 | if (implType === undefined) return undefined;
208 | switch (implType) {
209 | case ImplementationType.SPEECHMIKE_HID:
210 | return SpeechMikeHidDevice.create(hidDevice);
211 | case ImplementationType.POWERMIC_3:
212 | return PowerMic3Device.create(hidDevice);
213 | case ImplementationType.SPEECHMIKE_GAMEPAD:
214 | return SpeechMikeGamepadDevice.create(hidDevice);
215 | case ImplementationType.FOOT_CONTROL:
216 | return FootControlDevice.create(hidDevice);
217 | default:
218 | checkExhaustive(implType);
219 | }
220 | }
221 |
222 | protected assignPendingProxyDevices() {
223 | for (const [proxyHidDevice, proxyDevice] of this.pendingProxyDevices) {
224 | // Find matching host and assign
225 | for (const hostDevice of this.devices.values()) {
226 | if (hostDevice.implType !== ImplementationType.SPEECHMIKE_HID) {
227 | continue;
228 | }
229 | const hostHidDevice = hostDevice.hidDevice;
230 | if (proxyHidDevice.vendorId !== hostHidDevice.vendorId ||
231 | proxyHidDevice.productId !== hostHidDevice.productId) {
232 | continue;
233 | }
234 | hostDevice.assignProxyDevice(proxyDevice);
235 | this.pendingProxyDevices.delete(proxyHidDevice);
236 | break;
237 | }
238 | }
239 | }
240 |
241 | protected addListeners(device: DictationDevice) {
242 | for (const listener of this.buttonEventListeners) {
243 | device.addButtonEventListener(listener);
244 | }
245 |
246 | if (device.implType === ImplementationType.SPEECHMIKE_HID) {
247 | for (const listener of this.motionEventListeners) {
248 | device.addMotionEventListener(listener);
249 | }
250 | }
251 | }
252 |
253 | protected async onHidDeviceConnected(event: HIDConnectionEvent) {
254 | const hidDevice = event.device;
255 | const devices = await this.createAndAddInitializedDevices([hidDevice]);
256 | const device = devices[0];
257 | if (device === undefined) return;
258 |
259 | await Promise.all([...this.deviceConnectEventListeners].map(
260 | listener => listener(device)));
261 | }
262 |
263 | protected async onHidDeviceDisconnected(event: HIDConnectionEvent) {
264 | const hidDevice = event.device;
265 | this.pendingProxyDevices.delete(hidDevice);
266 | const device = this.devices.get(hidDevice);
267 | if (device === undefined) return;
268 |
269 | await device.shutdown(/*closeDevice=*/ false);
270 |
271 | await Promise.all([...this.deviceDisconnectEventListeners].map(
272 | listener => listener(device)));
273 |
274 | this.devices.delete(hidDevice);
275 | }
276 | }
277 |
278 | function getFilters(): HIDDeviceFilter[] {
279 | const filters: HIDDeviceFilter[] = [];
280 | for (const implType of Object.values(ImplementationType)) {
281 | if (typeof implType === 'string') continue;
282 | const filtersForImplType = DEVICE_FILTERS[implType];
283 | filters.push(...filtersForImplType);
284 | }
285 | return filters;
286 | }
287 |
288 | function getImplType(hidDevice: HIDDevice): ImplementationType|undefined {
289 | for (const implType of Object.values(ImplementationType)) {
290 | if (typeof implType === 'string') continue;
291 | const filtersForImplType = DEVICE_FILTERS[implType];
292 | if (deviceMatchesFilters(hidDevice, filtersForImplType)) return implType;
293 | }
294 | return undefined;
295 | }
296 |
297 | function deviceMatchesFilters(
298 | hidDevice: HIDDevice, filters: ReadonlyArray): boolean {
299 | return filters.some(filter => deviceMatchesFilter(hidDevice, filter));
300 | }
301 |
302 | function deviceMatchesFilter(
303 | hidDevice: HIDDevice, filter: Readonly): boolean {
304 | if (filter.vendorId !== undefined && hidDevice.vendorId !== filter.vendorId)
305 | return false;
306 | if (filter.productId !== undefined &&
307 | hidDevice.productId !== filter.productId)
308 | return false;
309 |
310 | if (filter.usagePage !== undefined) {
311 | if (hidDevice.collections === undefined ||
312 | hidDevice.collections.every(
313 | collection => collection.usagePage !== filter.usagePage)) {
314 | return false;
315 | }
316 | }
317 |
318 | if (filter.usage !== undefined) {
319 | if (hidDevice.collections === undefined ||
320 | hidDevice.collections.every(
321 | collection => collection.usage !== filter.usage)) {
322 | return false;
323 | }
324 | }
325 |
326 | return true;
327 | }
328 |
329 | function checkExhaustive(arg: never): never {
330 | throw new Error(`Unexpected input: ${arg}`);
331 | }
332 |
--------------------------------------------------------------------------------
/src/dictation_device_manager_test.ts:
--------------------------------------------------------------------------------
1 | import {DictationDevice} from './dictation_device';
2 | import {ImplementationType} from './dictation_device_base';
3 | import {DictationDeviceManager} from './dictation_device_manager';
4 | import {FootControlDevice} from './foot_control_device';
5 | import {PowerMic3Device} from './powermic_3_device';
6 | import {SpeechMikeGamepadDevice} from './speechmike_gamepad_device';
7 | import {SpeechMikeHidDevice} from './speechmike_hid_device';
8 | import {cleanState} from './test_util/clean_state';
9 | import {FakeHidApi} from './test_util/fake_hid_api';
10 | import {FakeHidDevice} from './test_util/fake_hid_device';
11 |
12 | type DeviceCreationTestCase = {
13 | name: string,
14 | hidDevices: FakeHidDevice[],
15 | expectedPowerMic3HidDeviceIndex?: number,
16 | expectedFootControlHidDeviceIndex?: number,
17 | expectedSpeechMikeHidHidDeviceIndex?: number,
18 | expectedSpeechMikeGamepadHidDeviceIndex?: number
19 | };
20 |
21 | const SAMPLE_HID_DEVICES: FakeHidDevice[] = [
22 | // PowerMic3
23 | new FakeHidDevice({
24 | vendorId: 0x0554,
25 | productId: 0x1001,
26 | collections: [{usagePage: 1, usage: 0}]
27 | }),
28 | // FootControl AC2330
29 | new FakeHidDevice({
30 | vendorId: 0x0911,
31 | productId: 0x091a,
32 | collections: [{usagePage: 1, usage: 4}]
33 | }),
34 | ];
35 |
36 | describe('DictationDeviceManager', () => {
37 | const state = cleanState<{
38 | buttonEventListener: jasmine.Spy,
39 | deviceManager: DictationDeviceManager,
40 | fakeHidApi: FakeHidApi,
41 | footControlDevice: jasmine.SpyObj,
42 | footControlCreateSpy: jasmine.Spy,
43 | motionEventListener: jasmine.Spy,
44 | powerMic3Device: jasmine.SpyObj,
45 | powerMic3CreateSpy: jasmine.Spy,
46 | speechMikeHidDevice: jasmine.SpyObj,
47 | speechMikeHidCreateSpy: jasmine.Spy,
48 | speechMikeGamepadDevice: jasmine.SpyObj,
49 | speechMikeGamepadCreateSpy: jasmine.Spy,
50 | }>();
51 |
52 | beforeEach(() => {
53 | state.fakeHidApi = new FakeHidApi();
54 |
55 | state.buttonEventListener = jasmine.createSpy('buttonEventListener');
56 | state.motionEventListener = jasmine.createSpy('motionEventListener');
57 |
58 | state.footControlCreateSpy = spyOn(FootControlDevice, 'create');
59 | state.footControlCreateSpy.and.callFake((hidDevice: HIDDevice) => {
60 | state.footControlDevice = jasmine.createSpyObj(
61 | 'footControlDevice',
62 | [
63 | 'addButtonEventListener',
64 | 'init',
65 | 'shutdown',
66 | ],
67 | {
68 | hidDevice,
69 | implType: ImplementationType.FOOT_CONTROL,
70 | });
71 | return state.footControlDevice;
72 | });
73 |
74 | state.powerMic3CreateSpy = spyOn(PowerMic3Device, 'create');
75 | state.powerMic3CreateSpy.and.callFake((hidDevice: HIDDevice) => {
76 | state.powerMic3Device = jasmine.createSpyObj(
77 | 'powerMic3Device',
78 | [
79 | 'addButtonEventListener',
80 | 'init',
81 | 'shutdown',
82 | ],
83 | {
84 | hidDevice,
85 | implType: ImplementationType.POWERMIC_3,
86 | });
87 | return state.powerMic3Device;
88 | });
89 |
90 | state.speechMikeHidCreateSpy = spyOn(SpeechMikeHidDevice, 'create');
91 | state.speechMikeHidCreateSpy.and.callFake((hidDevice: HIDDevice) => {
92 | state.speechMikeHidDevice = jasmine.createSpyObj(
93 | 'speechMikeHidDevice',
94 | [
95 | 'addButtonEventListener',
96 | 'addMotionEventListener',
97 | 'init',
98 | 'assignProxyDevice',
99 | 'shutdown',
100 | ],
101 | {
102 | hidDevice,
103 | implType: ImplementationType.SPEECHMIKE_HID,
104 | });
105 | return state.speechMikeHidDevice;
106 | });
107 |
108 | state.speechMikeGamepadCreateSpy = spyOn(SpeechMikeGamepadDevice, 'create');
109 | state.speechMikeGamepadCreateSpy.and.callFake((hidDevice: HIDDevice) => {
110 | state.speechMikeGamepadDevice =
111 | jasmine.createSpyObj(
112 | 'speechMikeGamepadDevice',
113 | [
114 | 'addButtonEventListener',
115 | 'init',
116 | 'shutdown',
117 | ],
118 | {
119 | hidDevice,
120 | implType: ImplementationType.SPEECHMIKE_GAMEPAD,
121 | });
122 | return state.speechMikeGamepadDevice;
123 | });
124 |
125 | state.deviceManager = new DictationDeviceManager(state.fakeHidApi);
126 | state.deviceManager.addButtonEventListener(state.buttonEventListener);
127 | state.deviceManager.addMotionEventListener(state.motionEventListener);
128 | });
129 |
130 | async function connectHidDevices(
131 | hidDevices: FakeHidDevice[], isPending = false) {
132 | for (const hidDevice of hidDevices) {
133 | await state.fakeHidApi.connectDevice(hidDevice, isPending);
134 | }
135 | }
136 |
137 | function checkDeviceCreation(
138 | devices: DictationDevice[], testCase: DeviceCreationTestCase) {
139 | const expectedDevices: jasmine.SpyObj[] = [];
140 |
141 | if (testCase.expectedPowerMic3HidDeviceIndex !== undefined) {
142 | const expectedPowerMic3HidDevice =
143 | testCase.hidDevices[testCase.expectedPowerMic3HidDeviceIndex];
144 | expect(PowerMic3Device.create)
145 | .toHaveBeenCalledOnceWith(expectedPowerMic3HidDevice);
146 | expectedDevices.push(state.powerMic3Device);
147 | }
148 | if (testCase.expectedFootControlHidDeviceIndex !== undefined) {
149 | const expectedFootControlHidDevice =
150 | testCase.hidDevices[testCase.expectedFootControlHidDeviceIndex];
151 | expect(FootControlDevice.create)
152 | .toHaveBeenCalledOnceWith(expectedFootControlHidDevice);
153 | expectedDevices.push(state.footControlDevice);
154 | }
155 | if (testCase.expectedSpeechMikeHidHidDeviceIndex !== undefined) {
156 | const expectedSpeechMikeHidHidDevice =
157 | testCase.hidDevices[testCase.expectedSpeechMikeHidHidDeviceIndex];
158 | expect(SpeechMikeHidDevice.create)
159 | .toHaveBeenCalledOnceWith(expectedSpeechMikeHidHidDevice);
160 | expectedDevices.push(state.speechMikeHidDevice);
161 | }
162 | if (testCase.expectedSpeechMikeGamepadHidDeviceIndex !== undefined) {
163 | const expectedSpeechMikeGamepadHidDevice =
164 | testCase.hidDevices[testCase.expectedSpeechMikeGamepadHidDeviceIndex];
165 | expect(SpeechMikeGamepadDevice.create)
166 | .toHaveBeenCalledOnceWith(expectedSpeechMikeGamepadHidDevice);
167 | expect(state.speechMikeHidDevice.assignProxyDevice)
168 | .toHaveBeenCalledOnceWith(state.speechMikeGamepadDevice);
169 | // Not adding to `expectedDevices` here since
170 | // SpeechMikeGamePadDicationDevices only show up as proxy within
171 | // SpeechMikeHidDictationDevices.
172 | }
173 |
174 | expect(devices).toEqual(expectedDevices);
175 |
176 | for (const device of expectedDevices) {
177 | expect(device.init).toHaveBeenCalled();
178 | expect(device.addButtonEventListener)
179 | .toHaveBeenCalledOnceWith(state.buttonEventListener);
180 | if (device.implType === ImplementationType.SPEECHMIKE_HID) {
181 | expect(state.speechMikeHidDevice.addMotionEventListener)
182 | .toHaveBeenCalledOnceWith(state.motionEventListener);
183 | }
184 | }
185 | }
186 |
187 | describe('creates the right device', () => {
188 | const testCases: DeviceCreationTestCase[] = [
189 | {
190 | name: 'PowerMic3',
191 | hidDevices: [
192 | new FakeHidDevice({
193 | vendorId: 0x0554,
194 | productId: 0x1001,
195 | collections: [{usagePage: 1, usage: 0}]
196 | }),
197 | ],
198 | expectedPowerMic3HidDeviceIndex: 0,
199 | },
200 | {
201 | name: 'FootControl ACC2310/2320',
202 | hidDevices: [
203 | new FakeHidDevice({
204 | vendorId: 0x0911,
205 | productId: 0x1844,
206 | collections: [{usagePage: 1, usage: 4}]
207 | }),
208 | ],
209 | expectedFootControlHidDeviceIndex: 0,
210 | },
211 | {
212 | name: 'FootControl ACC2330',
213 | hidDevices: [
214 | new FakeHidDevice({
215 | vendorId: 0x0911,
216 | productId: 0x091a,
217 | collections: [{usagePage: 1, usage: 4}]
218 | }),
219 | ],
220 | expectedFootControlHidDeviceIndex: 0,
221 | },
222 | {
223 | name: 'PowerMic4 (in either mode)',
224 | hidDevices: [
225 | new FakeHidDevice({
226 | vendorId: 0x0554,
227 | productId: 0x0064,
228 | collections: [{usagePage: 65440, usage: 1}]
229 | }),
230 | new FakeHidDevice({
231 | vendorId: 0x0554,
232 | productId: 0x0064,
233 | collections: [{usagePage: 1, usage: 4}]
234 | })
235 | ],
236 | expectedSpeechMikeHidHidDeviceIndex: 0,
237 | expectedSpeechMikeGamepadHidDeviceIndex: 1,
238 | },
239 | {
240 | name: 'SpeechOne PSM6000 (in either mode)',
241 | hidDevices: [
242 | new FakeHidDevice({
243 | vendorId: 0x0911,
244 | productId: 0x0c1e,
245 | collections: [{usagePage: 65440, usage: 1}]
246 | }),
247 | new FakeHidDevice({
248 | vendorId: 0x0911,
249 | productId: 0x0c1e,
250 | collections: [{usagePage: 1, usage: 4}]
251 | })
252 | ],
253 | expectedSpeechMikeHidHidDeviceIndex: 0,
254 | expectedSpeechMikeGamepadHidDeviceIndex: 1,
255 | },
256 | {
257 | name: 'SpeechMike 3xxx (in HID mode)',
258 | hidDevices: [
259 | new FakeHidDevice({
260 | vendorId: 0x0911,
261 | productId: 0x0c1c,
262 | collections: [{usagePage: 65440, usage: 1}]
263 | }),
264 | ],
265 | expectedSpeechMikeHidHidDeviceIndex: 0,
266 | },
267 | {
268 | name: 'SpeechMike 40xx (in HID mode)',
269 | hidDevices: [
270 | new FakeHidDevice({
271 | vendorId: 0x0911,
272 | productId: 0x0c1d,
273 | collections: [{usagePage: 65440, usage: 1}]
274 | }),
275 | ],
276 | expectedSpeechMikeHidHidDeviceIndex: 0,
277 | },
278 | {
279 | name: 'SpeechMike 3xxx/40xx (in Gamepad mode)',
280 | hidDevices: [
281 | new FakeHidDevice({
282 | vendorId: 0x0911,
283 | productId: 0x0fa0,
284 | collections: [{usagePage: 65440, usage: 1}]
285 | }),
286 | new FakeHidDevice({
287 | vendorId: 0x0911,
288 | productId: 0x0fa0,
289 | collections: [{usagePage: 1, usage: 4}]
290 | })
291 | ],
292 | expectedSpeechMikeHidHidDeviceIndex: 0,
293 | expectedSpeechMikeGamepadHidDeviceIndex: 1,
294 | }
295 | ];
296 |
297 | for (const testCase of testCases) {
298 | describe(testCase.name, () => {
299 | it('when already connected during init()', async () => {
300 | await connectHidDevices(testCase.hidDevices);
301 | await state.deviceManager.init();
302 |
303 | const devices = state.deviceManager.getDevices();
304 | checkDeviceCreation(devices, testCase);
305 | });
306 |
307 | it('when already connected and requested via requestDevice()',
308 | async () => {
309 | await connectHidDevices(testCase.hidDevices, /*isPending=*/ true);
310 | await state.deviceManager.init();
311 |
312 | const devicesAfterInit = state.deviceManager.getDevices();
313 | expect(devicesAfterInit).toEqual([]);
314 |
315 | const devicesAfterRequest =
316 | await state.deviceManager.requestDevice();
317 | checkDeviceCreation(devicesAfterRequest, testCase);
318 | });
319 |
320 | it('when newly connected', async () => {
321 | await state.deviceManager.init();
322 |
323 | await connectHidDevices(testCase.hidDevices);
324 |
325 | const devices = state.deviceManager.getDevices();
326 | checkDeviceCreation(devices, testCase);
327 | });
328 | });
329 | }
330 |
331 | it('sets the right filters for requestDevice()', async () => {
332 | spyOn(state.fakeHidApi, 'requestDevice').and.callThrough();
333 |
334 | await state.deviceManager.init();
335 | await state.deviceManager.requestDevice();
336 |
337 | expect(state.fakeHidApi.requestDevice).toHaveBeenCalledOnceWith({
338 | filters: [
339 | {vendorId: 2321, productId: 3100, usagePage: 65440, usage: 1},
340 | {vendorId: 2321, productId: 3101, usagePage: 65440, usage: 1},
341 | {vendorId: 2321, productId: 3102, usagePage: 65440, usage: 1},
342 | {vendorId: 2321, productId: 4000, usagePage: 65440, usage: 1},
343 | {vendorId: 1364, productId: 100, usagePage: 65440, usage: 1},
344 | {vendorId: 2321, productId: 4000, usagePage: 1, usage: 4},
345 | {vendorId: 2321, productId: 3102, usagePage: 1, usage: 4},
346 | {vendorId: 1364, productId: 100, usagePage: 1, usage: 4},
347 | {vendorId: 2321, productId: 6212, usagePage: 1, usage: 4},
348 | {vendorId: 2321, productId: 2330, usagePage: 1, usage: 4},
349 | {vendorId: 1364, productId: 4097, usagePage: 1, usage: 0}
350 | ]
351 | });
352 | });
353 |
354 | it('ignores irrelevant HIDDevices', async () => {
355 | const hidDevices: FakeHidDevice[] = [
356 | // Unknown vendor / product ID
357 | new FakeHidDevice({vendorId: 123, productId: 456}),
358 | // Known vendor / product, but wrong usagePage or usage
359 | new FakeHidDevice({
360 | vendorId: 0x0911,
361 | productId: 0x0c1c,
362 | }),
363 | new FakeHidDevice({
364 | vendorId: 0x0911,
365 | productId: 0x0c1c,
366 | collections: [{usagePage: 123, usage: 456}]
367 | }),
368 | ];
369 | await connectHidDevices(hidDevices);
370 |
371 | await state.deviceManager.init();
372 | const devices = state.deviceManager.getDevices();
373 | expect(devices).toEqual([]);
374 | });
375 | });
376 |
377 | describe('with connected devices', () => {
378 | beforeEach(async () => {
379 | await connectHidDevices(SAMPLE_HID_DEVICES);
380 |
381 | await state.deviceManager.init();
382 | const devices = state.deviceManager.getDevices();
383 | expect(devices).toEqual([state.powerMic3Device, state.footControlDevice]);
384 |
385 | expect(state.powerMic3Device.shutdown).not.toHaveBeenCalled();
386 | expect(state.footControlDevice.shutdown).not.toHaveBeenCalled();
387 | });
388 |
389 | it('handles disconnect', async () => {
390 | // Disconnect PowerMic3
391 | await state.fakeHidApi.disconnectDevice(SAMPLE_HID_DEVICES[0]);
392 | expect(state.powerMic3Device.shutdown)
393 | .toHaveBeenCalledOnceWith(/*closeDevice=*/ false);
394 | expect(state.footControlDevice.shutdown).not.toHaveBeenCalled();
395 | state.powerMic3Device.shutdown.calls.reset();
396 |
397 | // Disconnect PowerMic3
398 | await state.fakeHidApi.disconnectDevice(SAMPLE_HID_DEVICES[1]);
399 | expect(state.powerMic3Device.shutdown).not.toHaveBeenCalled();
400 | expect(state.footControlDevice.shutdown)
401 | .toHaveBeenCalledOnceWith(/*closeDevice=*/ false);
402 | });
403 |
404 | it('handles shut down', async () => {
405 | await state.deviceManager.shutdown();
406 |
407 | // Shuts down and closes devices
408 | expect(state.powerMic3Device.shutdown)
409 | .toHaveBeenCalledOnceWith(/*closeDevice=*/ true);
410 | expect(state.footControlDevice.shutdown)
411 | .toHaveBeenCalledOnceWith(/*closeDevice=*/ true);
412 |
413 | // Does not react to new connections
414 | state.powerMic3CreateSpy.calls.reset();
415 | state.footControlCreateSpy.calls.reset();
416 | await connectHidDevices(SAMPLE_HID_DEVICES);
417 | expect(state.powerMic3CreateSpy).not.toHaveBeenCalled();
418 | expect(state.footControlCreateSpy).not.toHaveBeenCalled();
419 | });
420 | });
421 |
422 | it('handles devices failing to initialize', async () => {
423 | state.powerMic3CreateSpy.and.callFake((hidDevice: HIDDevice) => {
424 | state.powerMic3Device = jasmine.createSpyObj(
425 | 'powerMic3Device',
426 | [
427 | 'addButtonEventListener',
428 | 'init',
429 | 'shutdown',
430 | ],
431 | {
432 | hidDevice,
433 | implType: ImplementationType.POWERMIC_3,
434 | });
435 | state.powerMic3Device.init.and.rejectWith('fail');
436 | return state.powerMic3Device;
437 | });
438 |
439 | await connectHidDevices(SAMPLE_HID_DEVICES);
440 |
441 | await state.deviceManager.init();
442 | const devices = state.deviceManager.getDevices();
443 | expect(devices).toEqual([state.footControlDevice]);
444 | });
445 | });
446 |
--------------------------------------------------------------------------------
/src/speechmike_hid_device.ts:
--------------------------------------------------------------------------------
1 | /**
2 | * @license
3 | * Copyright 2022 Google LLC
4 | *
5 | * Licensed under the Apache License, Version 2.0 (the "License");
6 | * you may not use this file except in compliance with the License.
7 | * You may obtain a copy of the License at
8 | *
9 | * http://www.apache.org/licenses/LICENSE-2.0
10 | *
11 | * Unless required by applicable law or agreed to in writing, software
12 | * distributed under the License is distributed on an "AS IS" BASIS,
13 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14 | * See the License for the specific language governing permissions and
15 | * limitations under the License.
16 | */
17 |
18 | import {DictationDevice} from './dictation_device';
19 | import {ButtonEvent, DeviceType, DictationDeviceBase, ImplementationType,} from './dictation_device_base';
20 | import {SpeechMikeGamepadDevice} from './speechmike_gamepad_device';
21 |
22 | export enum EventMode {
23 | HID = 0,
24 | KEYBOARD = 1,
25 | BROWSER = 2,
26 | WINDOWS_SR = 3,
27 | DRAGON_FOR_MAC = 4,
28 | DRAGON_FOR_WINDOWS = 5,
29 | }
30 |
31 | export enum SimpleLedState {
32 | OFF = 0,
33 | RECORD_INSERT = 1,
34 | RECORD_OVERWRITE = 2,
35 | RECORD_STANDBY_INSERT = 3,
36 | RECORD_STANDBY_OVERWRITE = 4,
37 | }
38 |
39 | export enum LedIndex {
40 | RECORD_LED_GREEN = 0,
41 | RECORD_LED_RED = 1,
42 | INSTRUCTION_LED_GREEN = 2,
43 | INSTRUCTION_LED_RED = 3,
44 | INS_OWR_BUTTON_LED_GREEN = 4,
45 | INS_OWR_BUTTON_LED_RED = 5,
46 | F1_BUTTON_LED = 6,
47 | F2_BUTTON_LED = 7,
48 | F3_BUTTON_LED = 8,
49 | F4_BUTTON_LED = 9,
50 | }
51 |
52 | export enum LedMode {
53 | OFF = 0,
54 | BLINK_SLOW = 1,
55 | BLINK_FAST = 2,
56 | ON = 3,
57 | }
58 |
59 | export type LedState = Record;
60 |
61 | export enum MotionEvent {
62 | PICKED_UP = 0,
63 | LAYED_DOWN = 1,
64 | }
65 |
66 | export type MotionEventListener =
67 | (device: DictationDevice, event: MotionEvent) => void|Promise;
68 |
69 | enum Command {
70 | SET_LED = 0x02,
71 | SET_EVENT_MODE = 0x0d,
72 | BUTTON_PRESS_EVENT = 0x80,
73 | IS_SPEECHMIKE_PREMIUM = 0x83,
74 | GET_DEVICE_CODE_SM3 = 0x87,
75 | GET_DEVICE_CODE_SMP = 0x8b,
76 | GET_DEVICE_CODE_SO = 0x96,
77 | GET_EVENT_MODE = 0x8d,
78 | WIRELESS_STATUS_EVENT = 0x94,
79 | MOTION_EVENT = 0x9e,
80 | GET_FIRMWARE_VERSION = 0x91,
81 | }
82 |
83 | const COMMAND_TIMEOUT_MS = 5000;
84 |
85 | const BUTTON_MAPPINGS_SPEECHMIKE = new Map([
86 | [ButtonEvent.REWIND, 1 << 12],
87 | [ButtonEvent.PLAY, 1 << 10],
88 | [ButtonEvent.FORWARD, 1 << 11],
89 | [ButtonEvent.INS_OVR, 1 << 14],
90 | [ButtonEvent.RECORD, 1 << 8],
91 | [ButtonEvent.COMMAND, 1 << 5],
92 | [ButtonEvent.STOP, 1 << 9],
93 | [ButtonEvent.INSTR, 1 << 15],
94 | [ButtonEvent.F1_A, 1 << 1],
95 | [ButtonEvent.F2_B, 1 << 2],
96 | [ButtonEvent.F3_C, 1 << 3],
97 | [ButtonEvent.F4_D, 1 << 4],
98 | [ButtonEvent.EOL_PRIO, 1 << 13],
99 | [ButtonEvent.SCAN_END, 1 << 0],
100 | [ButtonEvent.SCAN_SUCCESS, 1 << 7],
101 | ]);
102 |
103 | const BUTTON_MAPPINGS_POWERMIC_4 = new Map([
104 | [ButtonEvent.TAB_BACKWARD, 1 << 12],
105 | [ButtonEvent.PLAY, 1 << 10],
106 | [ButtonEvent.TAB_FORWARD, 1 << 11],
107 | [ButtonEvent.FORWARD, 1 << 14],
108 | [ButtonEvent.RECORD, 1 << 8],
109 | [ButtonEvent.COMMAND, 1 << 5],
110 | [ButtonEvent.ENTER_SELECT, 1 << 15],
111 | [ButtonEvent.F1_A, 1 << 1],
112 | [ButtonEvent.F2_B, 1 << 2],
113 | [ButtonEvent.F3_C, 1 << 3],
114 | [ButtonEvent.F4_D, 1 << 4],
115 | [ButtonEvent.REWIND, 1 << 13],
116 | ]);
117 |
118 | const LED_STATE_OFF: Readonly = Object.freeze({
119 | [LedIndex.RECORD_LED_GREEN]: LedMode.OFF,
120 | [LedIndex.RECORD_LED_RED]: LedMode.OFF,
121 | [LedIndex.INSTRUCTION_LED_GREEN]: LedMode.OFF,
122 | [LedIndex.INSTRUCTION_LED_RED]: LedMode.OFF,
123 | [LedIndex.INS_OWR_BUTTON_LED_GREEN]: LedMode.OFF,
124 | [LedIndex.INS_OWR_BUTTON_LED_RED]: LedMode.OFF,
125 | [LedIndex.F1_BUTTON_LED]: LedMode.OFF,
126 | [LedIndex.F2_BUTTON_LED]: LedMode.OFF,
127 | [LedIndex.F3_BUTTON_LED]: LedMode.OFF,
128 | [LedIndex.F4_BUTTON_LED]: LedMode.OFF,
129 | });
130 |
131 | const LED_STATE_RECORD_INSERT: Readonly = Object.freeze({
132 | ...LED_STATE_OFF,
133 | [LedIndex.RECORD_LED_GREEN]: LedMode.ON,
134 | [LedIndex.INS_OWR_BUTTON_LED_GREEN]: LedMode.ON,
135 | });
136 |
137 | const LED_STATE_RECORD_OVERWRITE: Readonly = Object.freeze({
138 | ...LED_STATE_OFF,
139 | [LedIndex.RECORD_LED_RED]: LedMode.ON,
140 | });
141 |
142 | const LED_STATE_RECORD_STANDBY_INSERT: Readonly = Object.freeze({
143 | ...LED_STATE_OFF,
144 | [LedIndex.RECORD_LED_GREEN]: LedMode.BLINK_SLOW,
145 | [LedIndex.INS_OWR_BUTTON_LED_GREEN]: LedMode.BLINK_SLOW,
146 | });
147 |
148 | const LED_STATE_RECORD_STANDBY_OVERWRITE: Readonly = Object.freeze({
149 | ...LED_STATE_OFF,
150 | [LedIndex.RECORD_LED_RED]: LedMode.BLINK_SLOW,
151 | });
152 |
153 | const SIMPLE_LED_STATES: Readonly>> =
154 | Object.freeze({
155 | [SimpleLedState.OFF]: LED_STATE_OFF,
156 | [SimpleLedState.RECORD_INSERT]: LED_STATE_RECORD_INSERT,
157 | [SimpleLedState.RECORD_OVERWRITE]: LED_STATE_RECORD_OVERWRITE,
158 | [SimpleLedState.RECORD_STANDBY_INSERT]: LED_STATE_RECORD_STANDBY_INSERT,
159 | [SimpleLedState.RECORD_STANDBY_OVERWRITE]:
160 | LED_STATE_RECORD_STANDBY_OVERWRITE,
161 | });
162 |
163 | const PHI_SLIDERS = Object.freeze([
164 | DeviceType.SPEECHMIKE_LFH_3220, DeviceType.SPEECHMIKE_LFH_3520,
165 | DeviceType.SPEECHMIKE_SMP_3720
166 | ]);
167 | const INT_SLIDERS = Object.freeze([
168 | DeviceType.SPEECHMIKE_LFH_3210, DeviceType.SPEECHMIKE_LFH_3310,
169 | DeviceType.SPEECHMIKE_LFH_3510, DeviceType.SPEECHMIKE_LFH_3610,
170 | DeviceType.SPEECHMIKE_SMP_3710, DeviceType.SPEECHMIKE_SMP_3810,
171 | DeviceType.SPEECHMIKE_SMP_4010
172 | ]);
173 |
174 | export class SpeechMikeHidDevice extends DictationDeviceBase {
175 | readonly implType = ImplementationType.SPEECHMIKE_HID;
176 |
177 | protected deviceCode = 0;
178 | protected ledState: LedState = {...LED_STATE_OFF};
179 | protected sliderBitsFilter = 0;
180 | protected lastSliderValue = 0;
181 | protected lastButtonValue = 0;
182 |
183 | protected commandResolvers = new Map void>();
184 | protected commandTimeouts = new Map();
185 |
186 | protected readonly motionEventListeners = new Set();
187 |
188 | protected proxyDevice: SpeechMikeGamepadDevice|undefined = undefined;
189 |
190 | static create(hidDevice: HIDDevice) {
191 | return new SpeechMikeHidDevice(hidDevice);
192 | }
193 |
194 | override async init() {
195 | await super.init();
196 | await this.fetchDeviceCode();
197 | this.determineSliderBitsFilter();
198 | }
199 |
200 | override async shutdown() {
201 | await super.shutdown();
202 |
203 | if (this.proxyDevice !== undefined) {
204 | await this.proxyDevice.shutdown();
205 | }
206 | }
207 |
208 | addMotionEventListener(listener: MotionEventListener) {
209 | this.motionEventListeners.add(listener);
210 | }
211 |
212 | getDeviceCode(): number {
213 | return this.deviceCode;
214 | }
215 |
216 | getDeviceType(): DeviceType {
217 | if (this.hidDevice.vendorId === 0x0554) {
218 | if (this.hidDevice.productId === 0x0064) {
219 | return DeviceType.POWERMIC_4;
220 | }
221 | return DeviceType.UNKNOWN;
222 | } else if (this.hidDevice.vendorId === 0x0911) {
223 | return this.deviceCode;
224 | }
225 | return DeviceType.UNKNOWN;
226 | }
227 |
228 | async setSimpleLedState(simpleLedState: SimpleLedState) {
229 | this.ledState = {...SIMPLE_LED_STATES[simpleLedState]};
230 | await this.sendLedState();
231 | }
232 |
233 | async setLed(index: LedIndex, mode: LedMode) {
234 | this.ledState[index] = mode;
235 | await this.sendLedState();
236 | }
237 |
238 | protected async sendLedState() {
239 | const input = [0, 0, 0, 0, 0, 0, 0, 0];
240 |
241 | input[5] |= this.ledState[LedIndex.RECORD_LED_GREEN] << 0;
242 | input[5] |= this.ledState[LedIndex.RECORD_LED_RED] << 2;
243 | input[5] |= this.ledState[LedIndex.INSTRUCTION_LED_GREEN] << 4;
244 | input[5] |= this.ledState[LedIndex.INSTRUCTION_LED_RED] << 6;
245 |
246 | input[6] |= this.ledState[LedIndex.INS_OWR_BUTTON_LED_GREEN] << 4;
247 | input[6] |= this.ledState[LedIndex.INS_OWR_BUTTON_LED_RED] << 6;
248 |
249 | input[7] |= this.ledState[LedIndex.F4_BUTTON_LED] << 0;
250 | input[7] |= this.ledState[LedIndex.F3_BUTTON_LED] << 2;
251 | input[7] |= this.ledState[LedIndex.F2_BUTTON_LED] << 4;
252 | input[7] |= this.ledState[LedIndex.F1_BUTTON_LED] << 6;
253 | await this.sendCommand(Command.SET_LED, input);
254 | }
255 |
256 | // See comment in DictationDeviceManager
257 | assignProxyDevice(proxyDevice: SpeechMikeGamepadDevice) {
258 | if (this.proxyDevice !== undefined) {
259 | throw new Error(
260 | 'Proxy device already assigned. Adding multiple SpeechMikes in Browser/Gamepad mode at the same time is not supported.');
261 | }
262 | this.proxyDevice = proxyDevice;
263 | this.proxyDevice.addButtonEventListener(
264 | (_device: DictationDevice, bitMask: ButtonEvent) =>
265 | this.onProxyButtonEvent(bitMask));
266 | }
267 |
268 | // See comment in DictationDeviceManager
269 | protected async onProxyButtonEvent(bitMask: ButtonEvent) {
270 | await Promise.all([...this.buttonEventListeners].map(
271 | listener => listener(this.getThisAsDictationDevice(), bitMask)));
272 | }
273 |
274 | protected async handleCommandResponse(command: Command, data: DataView) {
275 | const resolve = this.commandResolvers.get(command);
276 | if (resolve === undefined) {
277 | throw new Error(`Unexpected response for command ${command}`);
278 | }
279 | resolve(data);
280 | }
281 |
282 | async getEventMode(): Promise {
283 | const response =
284 | await this.sendCommandAndWaitForResponse(Command.GET_EVENT_MODE);
285 | const eventMode = response.getInt8(8);
286 | return eventMode;
287 | }
288 |
289 | async setEventMode(eventMode: EventMode) {
290 | const input = [0, 0, 0, 0, 0, 0, 0, eventMode];
291 | await this.sendCommand(Command.SET_EVENT_MODE, input);
292 | }
293 |
294 | protected async fetchDeviceCode() {
295 | let response =
296 | await this.sendCommandAndWaitForResponse(Command.IS_SPEECHMIKE_PREMIUM);
297 |
298 | if (response.getUint8(8) & 0x80) {
299 | // SpeechMike Premium or later. Let´s check whether it´s a SpeechOne,
300 | // or get the device code otherwise
301 | response =
302 | await this.sendCommandAndWaitForResponse(Command.GET_DEVICE_CODE_SMP);
303 |
304 | if (response.getUint8(1)) {
305 | // SpeechOne or later. We look for the device code somewhere else
306 | response = await this.sendCommandAndWaitForResponse(
307 | Command.GET_DEVICE_CODE_SO);
308 | // get SpeechOne device code
309 | this.deviceCode = response.getUint16(7);
310 |
311 | } else {
312 | // get SpeechMike Premium/Touch/Air device code
313 | const smpaCode = response.getUint16(2);
314 | const smptCode = response.getUint16(4);
315 | const smpCode = response.getUint16(6);
316 |
317 | this.deviceCode = Math.max(smpCode, smptCode, smpaCode);
318 |
319 | // if the device is SpeechMike Premium Air, then check for the Ambient
320 | if ([
321 | DeviceType.SPEECHMIKE_SMP_4000, DeviceType.SPEECHMIKE_SMP_4010
322 | ].includes(this.deviceCode)) {
323 | // first check the main firmware version, it will be 6 or higher for
324 | // Ambient
325 | response = await this.sendCommandAndWaitForResponse(
326 | Command.GET_FIRMWARE_VERSION);
327 | if (response.getUint8(5) === 6) {
328 | // we can now get the Ambient device code
329 | response = await this.sendCommandAndWaitForResponse(
330 | Command.GET_DEVICE_CODE_SO);
331 | const SMAmbientDeviceCode = response.getUint16(5);
332 | if (SMAmbientDeviceCode !== 0) {
333 | // use the SpeechMike Ambient device code
334 | this.deviceCode = SMAmbientDeviceCode;
335 | }
336 | }
337 | }
338 | }
339 | } else {
340 | response =
341 | await this.sendCommandAndWaitForResponse(Command.GET_DEVICE_CODE_SM3);
342 | // get SpeechMike 3 device code
343 | this.deviceCode = response.getUint16(7);
344 | }
345 | }
346 |
347 | protected determineSliderBitsFilter() {
348 | if (PHI_SLIDERS.includes(this.deviceCode)) {
349 | this.sliderBitsFilter = ButtonEvent.FORWARD + ButtonEvent.STOP +
350 | ButtonEvent.PLAY + ButtonEvent.REWIND;
351 | } else if (INT_SLIDERS.includes(this.deviceCode)) {
352 | // INT slider
353 | this.sliderBitsFilter = ButtonEvent.RECORD + ButtonEvent.STOP +
354 | ButtonEvent.PLAY + ButtonEvent.REWIND;
355 | }
356 | }
357 |
358 | protected override async onInputReport(event: HIDInputReportEvent) {
359 | const data = event.data;
360 | const command = data.getUint8(0);
361 |
362 | if (command === Command.BUTTON_PRESS_EVENT) {
363 | await this.handleButtonPress(data);
364 | } else if (command === Command.MOTION_EVENT) {
365 | await this.handleMotionEvent(data);
366 | } else if (command === Command.WIRELESS_STATUS_EVENT) {
367 | // Do nothing
368 | // Bytes 5 & 6 contain information about wireless/battery/charging state.
369 | } else if (this.commandResolvers.get(command) !== undefined) {
370 | await this.handleCommandResponse(command, data);
371 | } else {
372 | throw new Error(`Unhandled input report from command ${command}`);
373 | }
374 | }
375 |
376 | protected getButtonMappings(): Map {
377 | if (this.hidDevice.vendorId === 0x0554 &&
378 | this.hidDevice.productId === 0x0064) {
379 | return BUTTON_MAPPINGS_POWERMIC_4;
380 | }
381 | return BUTTON_MAPPINGS_SPEECHMIKE;
382 | }
383 |
384 | protected getInputBitmask(data: DataView): number {
385 | return data.getUint16(7, /* littleEndian= */ true);
386 | }
387 |
388 | protected getThisAsDictationDevice(): SpeechMikeHidDevice {
389 | return this;
390 | }
391 |
392 | protected async handleMotionEvent(data: DataView) {
393 | const inputBitMask = data.getUint8(8);
394 | const motionEvent =
395 | inputBitMask === 1 ? MotionEvent.LAYED_DOWN : MotionEvent.PICKED_UP;
396 |
397 | await Promise.all([...this.motionEventListeners].map(
398 | listener => listener(this.getThisAsDictationDevice(), motionEvent)));
399 | }
400 |
401 | protected async sendCommand(command: Command, input?: number[]) {
402 | const data = input === undefined ? new Uint8Array([command]) :
403 | new Uint8Array([command, ...input]);
404 | await this.hidDevice.sendReport(/* reportId= */ 0, data);
405 | }
406 |
407 | protected async sendCommandAndWaitForResponse(
408 | command: Command, input?: number[]): Promise {
409 | if (this.commandResolvers.has(command) ||
410 | this.commandTimeouts.has(command)) {
411 | throw new Error(`Command ${command} is already running`);
412 | }
413 |
414 | const responsePromise = new Promise(resolve => {
415 | this.commandResolvers.set(command, resolve);
416 | this.sendCommand(command, input);
417 | });
418 | const timeoutPromise = new Promise(resolve => {
419 | const timeoutId = window.setTimeout(() => {
420 | resolve(undefined);
421 | }, COMMAND_TIMEOUT_MS);
422 | this.commandTimeouts.set(command, timeoutId);
423 | });
424 |
425 | const result = await Promise.race([responsePromise, timeoutPromise]);
426 |
427 | this.commandResolvers.delete(command);
428 | this.commandTimeouts.delete(command);
429 |
430 | if (result === undefined) {
431 | throw new Error(
432 | `Command ${command} timed out after ${COMMAND_TIMEOUT_MS}ms`);
433 | }
434 |
435 | return result;
436 | }
437 |
438 | // For slider SpeechMikes the slider position is always added to any
439 | // non-slider button event (2 bits set in the bitmask in this case). We
440 | // don´t need that, so we unset the slider position bit
441 | protected override filterOutputBitMask(outputBitMask: number): number {
442 | // Return unfiltered bitmask if the device is not a slider device.
443 | if (!this.sliderBitsFilter) return outputBitMask;
444 |
445 | const buttonBitsFilter = ~this.sliderBitsFilter;
446 |
447 | const sliderValue = outputBitMask & this.sliderBitsFilter;
448 | const buttonValue = outputBitMask & buttonBitsFilter;
449 |
450 | const sliderChanged = sliderValue !== this.lastSliderValue;
451 | const buttonChanged = buttonValue !== this.lastButtonValue;
452 |
453 | this.lastSliderValue = sliderValue;
454 | this.lastButtonValue = buttonValue;
455 |
456 | if (sliderChanged && buttonChanged) {
457 | // This can only happen with the first button event, in which case we only
458 | // care about the button event.
459 | return outputBitMask & buttonBitsFilter;
460 | } else if (sliderChanged) {
461 | // Only return slider bits
462 | return outputBitMask & this.sliderBitsFilter;
463 | } else if (buttonChanged) {
464 | // Only return button bits
465 | return outputBitMask & buttonBitsFilter;
466 | }
467 |
468 | return outputBitMask;
469 | }
470 | }
471 |
--------------------------------------------------------------------------------
/src/speechmike_hid_device_test.ts:
--------------------------------------------------------------------------------
1 | import {ButtonEvent, ButtonEventListener, DeviceType, ImplementationType} from './dictation_device_base';
2 | import {SpeechMikeGamepadDevice} from './speechmike_gamepad_device';
3 | import {LedIndex, LedMode, MotionEvent, SimpleLedState, SpeechMikeHidDevice} from './speechmike_hid_device';
4 | import {ButtonMappingTestCase, checkButtonMapping} from './test_util/check_button_mapping';
5 | import {cleanState} from './test_util/clean_state';
6 | import {FakeHidDevice} from './test_util/fake_hid_device';
7 |
8 | describe('SpeechMikeHidDevice', () => {
9 | const state = cleanState<{
10 | buttonEventListener: jasmine.Spy,
11 | dictationDevice: SpeechMikeHidDevice,
12 | fakeHidDevice: FakeHidDevice,
13 | motionEventListener: jasmine.Spy,
14 | proxyDicationDevice: jasmine.SpyObj,
15 | proxyDicationDeviceButtonEventListeners: Set,
16 | sendReportReceiver: jasmine.Spy,
17 | }>();
18 |
19 | beforeEach(() => {
20 | state.buttonEventListener = jasmine.createSpy('buttonEventListener');
21 | state.motionEventListener = jasmine.createSpy('motionEventListener');
22 | state.sendReportReceiver = jasmine.createSpy('sendReportReceiver');
23 | state.sendReportReceiver.and.resolveTo();
24 |
25 | state.proxyDicationDevice =
26 | jasmine.createSpyObj('proxyDicationDevice', [
27 | 'addButtonEventListener',
28 | 'shutdown',
29 | ]);
30 | state.proxyDicationDeviceButtonEventListeners =
31 | new Set();
32 | state.proxyDicationDevice.addButtonEventListener.and.callFake(
33 | (listener: ButtonEventListener) => {
34 | state.proxyDicationDeviceButtonEventListeners.add(listener);
35 | });
36 | });
37 |
38 | async function createDictationDevice(
39 | properties: {vendorId: number, productId: number}) {
40 | state.fakeHidDevice = new FakeHidDevice(
41 | {...properties, sendReportReceiver: state.sendReportReceiver});
42 | state.dictationDevice = SpeechMikeHidDevice.create(state.fakeHidDevice);
43 | state.dictationDevice.addButtonEventListener(state.buttonEventListener);
44 | state.dictationDevice.addMotionEventListener(state.motionEventListener);
45 | state.dictationDevice.assignProxyDevice(state.proxyDicationDevice);
46 | }
47 |
48 | function prepareResponse(request: number[], response: number[]) {
49 | state.sendReportReceiver
50 | .withArgs(/* reportId= */ 0, new Uint8Array(request))
51 | .and.callFake(() => {
52 | state.fakeHidDevice.handleInputReport(response);
53 | });
54 | }
55 |
56 | async function createDictationDeviceForType(deviceType: DeviceType) {
57 | switch (deviceType) {
58 | case DeviceType.SPEECHMIKE_LFH_3200: {
59 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
60 | prepareResponse([131], [131, 2, 0, 0, 0, 0, 50, 0, 85]); // IS_PREMIUM
61 | prepareResponse(
62 | [135], [135, 0, 0, 0, 0, 0, 0, 12, 128]); // GET_DEVICE_CODE_SM3
63 | break;
64 | }
65 | case DeviceType.SPEECHMIKE_LFH_3210: {
66 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
67 | prepareResponse([131], [131, 2, 0, 0, 0, 0, 50, 0, 85]); // IS_PREMIUM
68 | prepareResponse(
69 | [135], [135, 0, 0, 0, 0, 0, 0, 12, 138]); // GET_DEVICE_CODE_SM3
70 | break;
71 | }
72 | case DeviceType.SPEECHMIKE_LFH_3220: {
73 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
74 | prepareResponse([131], [131, 2, 0, 0, 0, 0, 50, 0, 85]); // IS_PREMIUM
75 | prepareResponse(
76 | [135], [135, 0, 0, 0, 0, 0, 0, 12, 148]); // GET_DEVICE_CODE_SM3
77 | break;
78 | }
79 | case DeviceType.SPEECHMIKE_LFH_3300: {
80 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
81 | prepareResponse([131], [131, 2, 0, 0, 0, 0, 50, 0, 85]); // IS_PREMIUM
82 | prepareResponse(
83 | [135], [135, 0, 0, 0, 0, 0, 0, 12, 228]); // GET_DEVICE_CODE_SM3
84 | break;
85 | }
86 | case DeviceType.SPEECHMIKE_LFH_3310: {
87 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
88 | prepareResponse([131], [131, 2, 0, 0, 0, 0, 50, 0, 85]); // IS_PREMIUM
89 | prepareResponse(
90 | [135], [135, 0, 0, 0, 0, 0, 0, 12, 238]); // GET_DEVICE_CODE_SM3
91 | break;
92 | }
93 | case DeviceType.SPEECHMIKE_LFH_3500: {
94 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
95 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
96 | prepareResponse(
97 | [139], [139, 0, 0, 0, 0, 0, 13, 172, 0]); // GET_DEVICE_CODE_SMP
98 | break;
99 | }
100 | case DeviceType.SPEECHMIKE_LFH_3510: {
101 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
102 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
103 | prepareResponse(
104 | [139], [139, 0, 0, 0, 0, 0, 13, 182, 0]); // GET_DEVICE_CODE_SMP
105 | break;
106 | }
107 | case DeviceType.SPEECHMIKE_LFH_3520: {
108 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
109 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
110 | prepareResponse(
111 | [139], [139, 0, 0, 0, 0, 0, 13, 192, 0]); // GET_DEVICE_CODE_SMP
112 | break;
113 | }
114 | case DeviceType.SPEECHMIKE_LFH_3600: {
115 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
116 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
117 | prepareResponse(
118 | [139], [139, 0, 0, 0, 0, 0, 14, 16, 0]); // GET_DEVICE_CODE_SMP
119 | break;
120 | }
121 | case DeviceType.SPEECHMIKE_LFH_3610: {
122 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
123 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
124 | prepareResponse(
125 | [139], [139, 0, 0, 0, 0, 0, 14, 26, 0]); // GET_DEVICE_CODE_SMP
126 | break;
127 | }
128 | case DeviceType.SPEECHMIKE_SMP_3700: {
129 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
130 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
131 | prepareResponse(
132 | [139], [139, 0, 0, 0, 0, 0, 14, 116, 0]); // GET_DEVICE_CODE_SMP
133 | break;
134 | }
135 | case DeviceType.SPEECHMIKE_SMP_3710: {
136 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
137 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
138 | prepareResponse(
139 | [139], [139, 0, 0, 0, 0, 0, 14, 126, 0]); // GET_DEVICE_CODE_SMP
140 | break;
141 | }
142 | case DeviceType.SPEECHMIKE_SMP_3720: {
143 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
144 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
145 | prepareResponse(
146 | [139], [139, 0, 0, 0, 0, 0, 14, 136, 0]); // GET_DEVICE_CODE_SMP
147 | break;
148 | }
149 | case DeviceType.SPEECHMIKE_SMP_3800: {
150 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
151 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
152 | prepareResponse(
153 | [139], [139, 0, 0, 0, 14, 216, 14, 16, 0]); // GET_DEVICE_CODE_SMP
154 | break;
155 | }
156 | case DeviceType.SPEECHMIKE_SMP_3810: {
157 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1c});
158 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
159 | prepareResponse(
160 | [139], [139, 0, 0, 0, 14, 226, 14, 26, 0]); // GET_DEVICE_CODE_SMP
161 | break;
162 | }
163 | case DeviceType.SPEECHMIKE_SMP_4000: {
164 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1d});
165 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
166 | prepareResponse(
167 | [139],
168 | [139, 0, 15, 160, 14, 116, 13, 172, 0]); // GET_DEVICE_CODE_SMP
169 | prepareResponse(
170 | [145], [145, 0, 0, 65, 65, 4, 46, 0, 65]) // GET_FIRMWARE_VERSION
171 | break;
172 | }
173 | case DeviceType.SPEECHMIKE_SMP_4010: {
174 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1d});
175 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
176 | prepareResponse(
177 | [139],
178 | [139, 0, 15, 170, 14, 126, 13, 182, 0]); // GET_DEVICE_CODE_SMP
179 | prepareResponse(
180 | [145], [145, 0, 0, 65, 65, 4, 46, 0, 65]) // GET_FIRMWARE_VERSION
181 | break;
182 | }
183 | case DeviceType.SPEECHONE_PSM_6000: {
184 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1e});
185 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
186 | prepareResponse(
187 | [139],
188 | [139, 1, 15, 160, 14, 116, 13, 172, 0]); // GET_DEVICE_CODE_SMP
189 | prepareResponse(
190 | [150], [150, 0, 0, 0, 0, 0, 0, 23, 113]); // GET_DEVICE_CODE_SO
191 | break;
192 | }
193 | case DeviceType.POWERMIC_4: {
194 | await createDictationDevice({vendorId: 0x0554, productId: 0x0064});
195 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
196 | prepareResponse(
197 | [139], [139, 0, 0, 0, 14, 116, 13, 172, 0]); // GET_DEVICE_CODE_SMP
198 | break;
199 | }
200 | case DeviceType.SPEECHMIKE_AMBIENT_PSM5000: {
201 | await createDictationDevice({vendorId: 0x0911, productId: 0x0c1e});
202 | prepareResponse([131], [131, 0, 0, 0, 1, 0, 50, 1, 149]); // IS_PREMIUM
203 | prepareResponse(
204 | [139],
205 | [139, 0, 15, 160, 14, 116, 13, 172, 0]); // GET_DEVICE_CODE_SMP
206 | prepareResponse(
207 | [145], [145, 0, 0, 5, 7, 6, 46, 0, 7]) // GET_FIRMWARE_VERSION
208 | prepareResponse(
209 | [150], [150, 0, 0, 0, 0, 19, 136, 0, 0]); // GET_DEVICE_CODE_SO
210 | break;
211 | }
212 | default: {
213 | throw new Error(`Unhandled device type ${deviceType}`);
214 | }
215 | }
216 | await state.dictationDevice.init();
217 | }
218 |
219 | describe('creates the right device type', () => {
220 | const testCases: DeviceType[] = [
221 | DeviceType.SPEECHMIKE_LFH_3200, DeviceType.SPEECHMIKE_LFH_3210,
222 | DeviceType.SPEECHMIKE_LFH_3220, DeviceType.SPEECHMIKE_LFH_3300,
223 | DeviceType.SPEECHMIKE_LFH_3310, DeviceType.SPEECHMIKE_LFH_3500,
224 | DeviceType.SPEECHMIKE_LFH_3510, DeviceType.SPEECHMIKE_LFH_3520,
225 | DeviceType.SPEECHMIKE_LFH_3600, DeviceType.SPEECHMIKE_LFH_3610,
226 | DeviceType.SPEECHMIKE_SMP_3700, DeviceType.SPEECHMIKE_SMP_3710,
227 | DeviceType.SPEECHMIKE_SMP_3720, DeviceType.SPEECHMIKE_SMP_3800,
228 | DeviceType.SPEECHMIKE_SMP_3810, DeviceType.SPEECHMIKE_SMP_4000,
229 | DeviceType.SPEECHMIKE_SMP_4010, DeviceType.SPEECHONE_PSM_6000,
230 | DeviceType.POWERMIC_4, DeviceType.SPEECHMIKE_AMBIENT_PSM5000,
231 | ];
232 |
233 | for (const deviceType of testCases) {
234 | it(DeviceType[deviceType], async () => {
235 | await createDictationDeviceForType(deviceType);
236 | expect(state.dictationDevice.getDeviceType()).toBe(deviceType);
237 | expect(state.dictationDevice.implType)
238 | .toBe(ImplementationType.SPEECHMIKE_HID);
239 | });
240 | }
241 | });
242 |
243 | it('setLed()', async () => {
244 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_SMP_3700);
245 |
246 | const testCases:
247 | {index: LedIndex, mode: LedMode, expectedCommandData: number[]}[] = [
248 | {
249 | index: LedIndex.RECORD_LED_GREEN,
250 | mode: LedMode.BLINK_SLOW,
251 | expectedCommandData: [2, 0, 0, 0, 0, 0, 1, 0, 0]
252 | },
253 | {
254 | index: LedIndex.RECORD_LED_GREEN,
255 | mode: LedMode.BLINK_FAST,
256 | expectedCommandData: [2, 0, 0, 0, 0, 0, 2, 0, 0]
257 | },
258 | {
259 | index: LedIndex.RECORD_LED_GREEN,
260 | mode: LedMode.ON,
261 | expectedCommandData: [2, 0, 0, 0, 0, 0, 3, 0, 0]
262 | },
263 | {
264 | index: LedIndex.RECORD_LED_RED,
265 | mode: LedMode.BLINK_SLOW,
266 | expectedCommandData: [2, 0, 0, 0, 0, 0, 4, 0, 0]
267 | },
268 | {
269 | index: LedIndex.RECORD_LED_RED,
270 | mode: LedMode.BLINK_FAST,
271 | expectedCommandData: [2, 0, 0, 0, 0, 0, 8, 0, 0]
272 | },
273 | {
274 | index: LedIndex.RECORD_LED_RED,
275 | mode: LedMode.ON,
276 | expectedCommandData: [2, 0, 0, 0, 0, 0, 12, 0, 0]
277 | },
278 | {
279 | index: LedIndex.INSTRUCTION_LED_GREEN,
280 | mode: LedMode.BLINK_SLOW,
281 | expectedCommandData: [2, 0, 0, 0, 0, 0, 16, 0, 0]
282 | },
283 | {
284 | index: LedIndex.INSTRUCTION_LED_GREEN,
285 | mode: LedMode.BLINK_FAST,
286 | expectedCommandData: [2, 0, 0, 0, 0, 0, 32, 0, 0]
287 | },
288 | {
289 | index: LedIndex.INSTRUCTION_LED_GREEN,
290 | mode: LedMode.ON,
291 | expectedCommandData: [2, 0, 0, 0, 0, 0, 48, 0, 0]
292 | },
293 | {
294 | index: LedIndex.INSTRUCTION_LED_RED,
295 | mode: LedMode.BLINK_SLOW,
296 | expectedCommandData: [2, 0, 0, 0, 0, 0, 64, 0, 0]
297 | },
298 | {
299 | index: LedIndex.INSTRUCTION_LED_RED,
300 | mode: LedMode.BLINK_FAST,
301 | expectedCommandData: [2, 0, 0, 0, 0, 0, 128, 0, 0]
302 | },
303 | {
304 | index: LedIndex.INSTRUCTION_LED_RED,
305 | mode: LedMode.ON,
306 | expectedCommandData: [2, 0, 0, 0, 0, 0, 192, 0, 0]
307 | },
308 | {
309 | index: LedIndex.INS_OWR_BUTTON_LED_GREEN,
310 | mode: LedMode.BLINK_SLOW,
311 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 16, 0]
312 | },
313 | {
314 | index: LedIndex.INS_OWR_BUTTON_LED_GREEN,
315 | mode: LedMode.BLINK_FAST,
316 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 32, 0]
317 | },
318 | {
319 | index: LedIndex.INS_OWR_BUTTON_LED_GREEN,
320 | mode: LedMode.ON,
321 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 48, 0]
322 | },
323 | {
324 | index: LedIndex.INS_OWR_BUTTON_LED_RED,
325 | mode: LedMode.BLINK_SLOW,
326 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 64, 0]
327 | },
328 | {
329 | index: LedIndex.INS_OWR_BUTTON_LED_RED,
330 | mode: LedMode.BLINK_FAST,
331 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 128, 0]
332 | },
333 | {
334 | index: LedIndex.INS_OWR_BUTTON_LED_RED,
335 | mode: LedMode.ON,
336 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 192, 0]
337 | },
338 | {
339 | index: LedIndex.F4_BUTTON_LED,
340 | mode: LedMode.BLINK_SLOW,
341 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 1]
342 | },
343 | {
344 | index: LedIndex.F4_BUTTON_LED,
345 | mode: LedMode.BLINK_FAST,
346 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 2]
347 | },
348 | {
349 | index: LedIndex.F4_BUTTON_LED,
350 | mode: LedMode.ON,
351 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 3]
352 | },
353 | {
354 | index: LedIndex.F3_BUTTON_LED,
355 | mode: LedMode.BLINK_SLOW,
356 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 4]
357 | },
358 | {
359 | index: LedIndex.F3_BUTTON_LED,
360 | mode: LedMode.BLINK_FAST,
361 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 8]
362 | },
363 | {
364 | index: LedIndex.F3_BUTTON_LED,
365 | mode: LedMode.ON,
366 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 12]
367 | },
368 | {
369 | index: LedIndex.F2_BUTTON_LED,
370 | mode: LedMode.BLINK_SLOW,
371 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 16]
372 | },
373 | {
374 | index: LedIndex.F2_BUTTON_LED,
375 | mode: LedMode.BLINK_FAST,
376 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 32]
377 | },
378 | {
379 | index: LedIndex.F2_BUTTON_LED,
380 | mode: LedMode.ON,
381 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 48]
382 | },
383 | {
384 | index: LedIndex.F1_BUTTON_LED,
385 | mode: LedMode.BLINK_SLOW,
386 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 64]
387 | },
388 | {
389 | index: LedIndex.F1_BUTTON_LED,
390 | mode: LedMode.BLINK_FAST,
391 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 128]
392 | },
393 | {
394 | index: LedIndex.F1_BUTTON_LED,
395 | mode: LedMode.ON,
396 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 192]
397 | },
398 | ];
399 | for (const testCase of testCases) {
400 | const contextOn =
401 | `${LedIndex[testCase.index]} set to ${LedMode[testCase.mode]}`;
402 | const contextOff =
403 | `${LedIndex[testCase.index]} set to ${LedMode[LedMode.OFF]}`;
404 |
405 | // Set LED
406 | state.sendReportReceiver.calls.reset();
407 | await state.dictationDevice.setLed(testCase.index, testCase.mode);
408 | expect(state.sendReportReceiver)
409 | .withContext(contextOn)
410 | .toHaveBeenCalledOnceWith(
411 | /* reportId= */ 0, new Uint8Array(testCase.expectedCommandData));
412 |
413 | // Turn off LED
414 | state.sendReportReceiver.calls.reset();
415 | await state.dictationDevice.setLed(testCase.index, LedMode.OFF);
416 | expect(state.sendReportReceiver)
417 | .withContext(contextOff)
418 | .toHaveBeenCalledOnceWith(
419 | /* reportId= */ 0, new Uint8Array([2, 0, 0, 0, 0, 0, 0, 0, 0]));
420 | }
421 | });
422 |
423 | it('setSimpleLedState()', async () => {
424 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_SMP_3700);
425 |
426 | const testCases: {state: SimpleLedState, expectedCommandData: number[]}[] =
427 | [
428 | {
429 | state: SimpleLedState.OFF,
430 | expectedCommandData: [2, 0, 0, 0, 0, 0, 0, 0, 0]
431 | },
432 | {
433 | state: SimpleLedState.RECORD_INSERT,
434 | expectedCommandData: [2, 0, 0, 0, 0, 0, 3, 48, 0]
435 | },
436 | {
437 | state: SimpleLedState.RECORD_OVERWRITE,
438 | expectedCommandData: [2, 0, 0, 0, 0, 0, 12, 0, 0]
439 | },
440 | {
441 | state: SimpleLedState.RECORD_STANDBY_INSERT,
442 | expectedCommandData: [2, 0, 0, 0, 0, 0, 1, 16, 0]
443 | },
444 | {
445 | state: SimpleLedState.RECORD_STANDBY_OVERWRITE,
446 | expectedCommandData: [2, 0, 0, 0, 0, 0, 4, 0, 0]
447 | },
448 | ];
449 | for (const testCase of testCases) {
450 | state.sendReportReceiver.calls.reset();
451 | await state.dictationDevice.setSimpleLedState(testCase.state);
452 | expect(state.sendReportReceiver)
453 | .withContext(SimpleLedState[testCase.state])
454 | .toHaveBeenCalledOnceWith(
455 | /* reportId= */ 0, new Uint8Array(testCase.expectedCommandData));
456 | }
457 | });
458 |
459 | it('handles motion events', async () => {
460 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_SMP_3700);
461 |
462 | expect(state.motionEventListener).not.toHaveBeenCalled();
463 |
464 | // PICKED_UP
465 | await state.fakeHidDevice.handleInputReport([158, 0, 0, 0, 0, 0, 0, 0, 0]);
466 | expect(state.motionEventListener)
467 | .toHaveBeenCalledOnceWith(state.dictationDevice, MotionEvent.PICKED_UP);
468 | state.motionEventListener.calls.reset();
469 |
470 | // LAYED_DOWN
471 | await state.fakeHidDevice.handleInputReport([158, 0, 0, 0, 0, 0, 0, 0, 1]);
472 | expect(state.motionEventListener)
473 | .toHaveBeenCalledOnceWith(
474 | state.dictationDevice, MotionEvent.LAYED_DOWN);
475 | });
476 |
477 | describe('handles input reports', () => {
478 | it('SpeechMikes_Pro', async () => {
479 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_SMP_3700);
480 |
481 | const testCases: ButtonMappingTestCase[] = [
482 | {
483 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 1, 0],
484 | expectedButtonEvents: ButtonEvent.SCAN_END
485 | },
486 | {
487 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 2, 0],
488 | expectedButtonEvents: ButtonEvent.F1_A
489 | },
490 | {
491 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 4, 0],
492 | expectedButtonEvents: ButtonEvent.F2_B
493 | },
494 | {
495 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 8, 0],
496 | expectedButtonEvents: ButtonEvent.F3_C
497 | },
498 | {
499 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 16, 0],
500 | expectedButtonEvents: ButtonEvent.F4_D
501 | },
502 | {
503 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 32, 0],
504 | expectedButtonEvents: ButtonEvent.COMMAND
505 | },
506 | {
507 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 64, 0],
508 | expectedButtonEvents: undefined
509 | },
510 | {
511 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 128, 0],
512 | expectedButtonEvents: ButtonEvent.SCAN_SUCCESS
513 | },
514 | {
515 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 1],
516 | expectedButtonEvents: ButtonEvent.RECORD
517 | },
518 | {
519 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 2],
520 | expectedButtonEvents: ButtonEvent.STOP
521 | },
522 | {
523 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 4],
524 | expectedButtonEvents: ButtonEvent.PLAY
525 | },
526 | {
527 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 8],
528 | expectedButtonEvents: ButtonEvent.FORWARD
529 | },
530 | {
531 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 16],
532 | expectedButtonEvents: ButtonEvent.REWIND
533 | },
534 | {
535 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 32],
536 | expectedButtonEvents: ButtonEvent.EOL_PRIO
537 | },
538 | {
539 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 64],
540 | expectedButtonEvents: ButtonEvent.INS_OVR
541 | },
542 | {
543 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 128],
544 | expectedButtonEvents: ButtonEvent.INSTR
545 | },
546 | ];
547 | const resetButtonInputReport = [128, 0, 0, 0, 0, 0, 0, 0, 0];
548 | await checkButtonMapping(
549 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
550 | testCases, resetButtonInputReport);
551 | });
552 |
553 | describe('SpeechMikes_INTSlider', () => {
554 | beforeEach(async () => {
555 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_SMP_3710);
556 | });
557 |
558 | it('Buttons', async () => {
559 | const testCases: ButtonMappingTestCase[] = [
560 | {
561 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 1, 0],
562 | expectedButtonEvents: ButtonEvent.SCAN_END
563 | },
564 | {
565 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 2, 0],
566 | expectedButtonEvents: ButtonEvent.F1_A
567 | },
568 | {
569 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 4, 0],
570 | expectedButtonEvents: ButtonEvent.F2_B
571 | },
572 | {
573 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 8, 0],
574 | expectedButtonEvents: ButtonEvent.F3_C
575 | },
576 | {
577 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 16, 0],
578 | expectedButtonEvents: ButtonEvent.F4_D
579 | },
580 | {
581 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 32, 0],
582 | expectedButtonEvents: ButtonEvent.COMMAND
583 | },
584 | {
585 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 64, 0],
586 | expectedButtonEvents: undefined
587 | },
588 | {
589 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 128, 0],
590 | expectedButtonEvents: ButtonEvent.SCAN_SUCCESS
591 | },
592 | {
593 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 8],
594 | expectedButtonEvents: ButtonEvent.FORWARD
595 | },
596 | {
597 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 32],
598 | expectedButtonEvents: ButtonEvent.EOL_PRIO
599 | },
600 | {
601 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 64],
602 | expectedButtonEvents: ButtonEvent.INS_OVR
603 | },
604 | {
605 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 128],
606 | expectedButtonEvents: ButtonEvent.INSTR
607 | },
608 | ];
609 | const resetButtonInputReport = [128, 0, 0, 0, 0, 0, 0, 0, 0];
610 | await checkButtonMapping(
611 | state.fakeHidDevice, state.dictationDevice,
612 | state.buttonEventListener, testCases, resetButtonInputReport);
613 | });
614 |
615 | it('Slider', async () => {
616 | const testCases: ButtonMappingTestCase[] = [
617 | {
618 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 1],
619 | expectedButtonEvents: ButtonEvent.RECORD
620 | },
621 | {
622 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 2],
623 | expectedButtonEvents: ButtonEvent.STOP
624 | },
625 | {
626 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 4],
627 | expectedButtonEvents: ButtonEvent.PLAY
628 | },
629 | {
630 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 16],
631 | expectedButtonEvents: ButtonEvent.REWIND
632 | },
633 | ];
634 | const resetInputReport = undefined; // no reset
635 | await checkButtonMapping(
636 | state.fakeHidDevice, state.dictationDevice,
637 | state.buttonEventListener, testCases, resetInputReport);
638 | });
639 |
640 | it('Slider + Button', async () => {
641 | const testCases: ButtonMappingTestCase[] = [
642 | {
643 | // Button: SCAN_END, Slider: PLAY
644 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 1, 4],
645 | expectedButtonEvents: ButtonEvent.SCAN_END
646 | },
647 | {
648 | // Button: None, Slider: PLAY
649 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 4],
650 | expectedButtonEvents: ButtonEvent.NONE
651 | },
652 | {
653 | // Button: None, Slider: STOP
654 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 2],
655 | expectedButtonEvents: ButtonEvent.STOP
656 | },
657 | {
658 | // Button: F1_A, Slider: STOP
659 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 2, 2],
660 | expectedButtonEvents: ButtonEvent.F1_A
661 | },
662 | ];
663 | const resetInputReport = undefined; // no reset
664 | await checkButtonMapping(
665 | state.fakeHidDevice, state.dictationDevice,
666 | state.buttonEventListener, testCases, resetInputReport);
667 | });
668 | });
669 |
670 | describe('SpeechMikes_PHISlider', () => {
671 | beforeEach(async () => {
672 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_LFH_3520);
673 | });
674 |
675 | it('Buttons', async () => {
676 | const testCases: ButtonMappingTestCase[] = [
677 | {
678 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 1, 0],
679 | expectedButtonEvents: ButtonEvent.SCAN_END
680 | },
681 | {
682 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 2, 0],
683 | expectedButtonEvents: ButtonEvent.F1_A
684 | },
685 | {
686 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 4, 0],
687 | expectedButtonEvents: ButtonEvent.F2_B
688 | },
689 | {
690 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 8, 0],
691 | expectedButtonEvents: ButtonEvent.F3_C
692 | },
693 | {
694 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 16, 0],
695 | expectedButtonEvents: ButtonEvent.F4_D
696 | },
697 | {
698 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 32, 0],
699 | expectedButtonEvents: ButtonEvent.COMMAND
700 | },
701 | {
702 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 64, 0],
703 | expectedButtonEvents: undefined
704 | },
705 | {
706 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 128, 0],
707 | expectedButtonEvents: ButtonEvent.SCAN_SUCCESS
708 | },
709 | {
710 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 1],
711 | expectedButtonEvents: ButtonEvent.RECORD
712 | },
713 | {
714 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 32],
715 | expectedButtonEvents: ButtonEvent.EOL_PRIO
716 | },
717 | {
718 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 64],
719 | expectedButtonEvents: ButtonEvent.INS_OVR
720 | },
721 | {
722 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 128],
723 | expectedButtonEvents: ButtonEvent.INSTR
724 | },
725 | ];
726 | const resetButtonInputReport = [128, 0, 0, 0, 0, 0, 0, 0, 0];
727 | await checkButtonMapping(
728 | state.fakeHidDevice, state.dictationDevice,
729 | state.buttonEventListener, testCases, resetButtonInputReport);
730 | });
731 |
732 | it('Slider', async () => {
733 | const testCases: ButtonMappingTestCase[] = [
734 | {
735 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 8],
736 | expectedButtonEvents: ButtonEvent.FORWARD
737 | },
738 | {
739 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 2],
740 | expectedButtonEvents: ButtonEvent.STOP
741 | },
742 | {
743 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 4],
744 | expectedButtonEvents: ButtonEvent.PLAY
745 | },
746 | {
747 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 16],
748 | expectedButtonEvents: ButtonEvent.REWIND
749 | },
750 | ];
751 | const resetInputReport = undefined; // no reset
752 | await checkButtonMapping(
753 | state.fakeHidDevice, state.dictationDevice,
754 | state.buttonEventListener, testCases, resetInputReport);
755 | });
756 |
757 | it('Slider + Button', async () => {
758 | const testCases: ButtonMappingTestCase[] = [
759 | {
760 | // Button: SCAN_END, Slider: PLAY
761 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 1, 4],
762 | expectedButtonEvents: ButtonEvent.SCAN_END
763 | },
764 | {
765 | // Button: None, Slider: PLAY
766 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 4],
767 | expectedButtonEvents: ButtonEvent.NONE
768 | },
769 | {
770 | // Button: None, Slider: STOP
771 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 2],
772 | expectedButtonEvents: ButtonEvent.STOP
773 | },
774 | {
775 | // Button: F1_A, Slider: STOP
776 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 2, 2],
777 | expectedButtonEvents: ButtonEvent.F1_A
778 | },
779 | ];
780 | const resetInputReport = undefined; // no reset
781 | await checkButtonMapping(
782 | state.fakeHidDevice, state.dictationDevice,
783 | state.buttonEventListener, testCases, resetInputReport);
784 | });
785 | });
786 |
787 | it('PowerMic4', async () => {
788 | await createDictationDeviceForType(DeviceType.POWERMIC_4);
789 |
790 | const testCases: ButtonMappingTestCase[] = [
791 | {
792 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 1, 0],
793 | expectedButtonEvents: undefined
794 | },
795 | {
796 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 2, 0],
797 | expectedButtonEvents: ButtonEvent.F1_A
798 | },
799 | {
800 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 4, 0],
801 | expectedButtonEvents: ButtonEvent.F2_B
802 | },
803 | {
804 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 8, 0],
805 | expectedButtonEvents: ButtonEvent.F3_C
806 | },
807 | {
808 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 16, 0],
809 | expectedButtonEvents: ButtonEvent.F4_D
810 | },
811 | {
812 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 32, 0],
813 | expectedButtonEvents: ButtonEvent.COMMAND
814 | },
815 | {
816 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 64, 0],
817 | expectedButtonEvents: undefined
818 | },
819 | {
820 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 128, 0],
821 | expectedButtonEvents: undefined
822 | },
823 | {
824 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 1],
825 | expectedButtonEvents: ButtonEvent.RECORD
826 | },
827 | {
828 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 2],
829 | expectedButtonEvents: undefined
830 | },
831 | {
832 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 4],
833 | expectedButtonEvents: ButtonEvent.PLAY
834 | },
835 | {
836 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 8],
837 | expectedButtonEvents: ButtonEvent.TAB_FORWARD
838 | },
839 | {
840 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 16],
841 | expectedButtonEvents: ButtonEvent.TAB_BACKWARD
842 | },
843 | {
844 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 32],
845 | expectedButtonEvents: ButtonEvent.REWIND
846 | },
847 | {
848 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 64],
849 | expectedButtonEvents: ButtonEvent.FORWARD
850 | },
851 | {
852 | inputReportData: [128, 0, 0, 0, 0, 0, 0, 0, 128],
853 | expectedButtonEvents: ButtonEvent.ENTER_SELECT
854 | },
855 | ];
856 | const resetButtonInputReport = [128, 0, 0, 0, 0, 0, 0, 0, 0];
857 | await checkButtonMapping(
858 | state.fakeHidDevice, state.dictationDevice, state.buttonEventListener,
859 | testCases, resetButtonInputReport);
860 | });
861 | });
862 |
863 | describe('proxy device', () => {
864 | beforeEach(async () => {
865 | await createDictationDeviceForType(DeviceType.SPEECHMIKE_SMP_3700);
866 | });
867 |
868 | it('propagates shutdown signal to proxy device', async () => {
869 | expect(state.proxyDicationDevice.shutdown).not.toHaveBeenCalled();
870 | await state.dictationDevice.shutdown();
871 | expect(state.proxyDicationDevice.shutdown).toHaveBeenCalled();
872 | });
873 |
874 | it('propagates button press events from proxy device', async () => {
875 | const button = ButtonEvent.RECORD;
876 | expect(state.buttonEventListener).not.toHaveBeenCalled();
877 | await Promise.all([...state.proxyDicationDeviceButtonEventListeners].map(
878 | listener => listener(state.proxyDicationDevice, button)));
879 | expect(state.buttonEventListener)
880 | .toHaveBeenCalledOnceWith(state.dictationDevice, button);
881 | });
882 | });
883 | });
884 |
--------------------------------------------------------------------------------