├── .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 |
162 | SpeechMike / PowerMic4: 163 |

164 | 165 | 166 | 174 |

175 |

176 |

177 | 178 | 185 |
186 |
187 | 188 | 200 | 206 |
207 |

208 |
209 |
210 | PowerMic3: 211 |

212 | 213 | 218 |

219 | 220 |
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 | --------------------------------------------------------------------------------