├── .editorconfig ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── demo ├── .gitignore ├── fuse.js └── src │ ├── index.html │ └── main.ts ├── package.json ├── prettier.config.js ├── src ├── lib │ ├── muse-interfaces.ts │ ├── muse-parse.spec.ts │ ├── muse-parse.ts │ ├── muse-utils.spec.ts │ ├── muse-utils.ts │ ├── zip-samples.spec.ts │ ├── zip-samples.ts │ ├── zip-samplesPpg.spec.ts │ └── zip-samplesPpg.ts ├── muse.spec.ts └── muse.ts ├── tsconfig.json ├── tslint.json ├── wallaby.js └── yarn.lock /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | indent_style = space 5 | indent_size = 4 6 | 7 | [{package.json, tsconfig.json, tslint.json}] 8 | indent_style=space 9 | indent_size=2 10 | 11 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .fusebox 2 | node_modules 3 | dist 4 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | sudo: false 3 | node_js: 4 | - 7 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2017 Uri Shaked and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # muse-js 2 | 3 | [![Build Status](https://travis-ci.org/urish/muse-js.png?branch=master)](https://travis-ci.org/urish/muse-js) 4 | 5 | Muse 1, Muse 2, and Muse S EEG Headset JavaScript Library (using Web Bluetooth). 6 | 7 | ## Running the demo app 8 | 9 | yarn 10 | yarn start 11 | 12 | and then open http://localhost:4445/ 13 | 14 | ## Usage example 15 | 16 | ```javascript 17 | 18 | import { MuseClient } from 'muse-js'; 19 | 20 | async function main() { 21 | let client = new MuseClient(); 22 | await client.connect(); 23 | await client.start(); 24 | client.eegReadings.subscribe(reading => { 25 | console.log(reading); 26 | }); 27 | client.telemetryData.subscribe(telemetry => { 28 | console.log(telemetry); 29 | }); 30 | client.accelerometerData.subscribe(acceleration => { 31 | console.log(acceleration); 32 | }); 33 | } 34 | 35 | main(); 36 | ``` 37 | 38 | ## Using in node.js 39 | 40 | You can use this library to connect to the Muse EEG headset from your node.js application. 41 | Use the [bleat](https://github.com/thegecko/bleat) package which emulates the Web Bluetooth API on top of [noble](https://github.com/sandeepmistry/noble): 42 | 43 | ```javascript 44 | const noble = require('noble'); 45 | const bluetooth = require('bleat').webbluetooth; 46 | 47 | async function connect() { 48 | let device = await bluetooth.requestDevice({ 49 | filters: [{ services: [MUSE_SERVICE] }] 50 | }); 51 | const gatt = await device.gatt.connect(); 52 | const client = new MuseClient(); 53 | await client.connect(gatt); 54 | await client.start(); 55 | // Now do whatever with muse client... 56 | } 57 | 58 | noble.on('stateChange', (state) => { 59 | if (state === 'poweredOn') { 60 | connect(); 61 | } 62 | }); 63 | ``` 64 | 65 | You can find a fully working example in the [muse-lsl repo](https://github.com/urish/muse-lsl/blob/master/index.js). 66 | 67 | ## Auxiliary Electrode 68 | 69 | The Muse 2016 EEG headsets contains four electrodes, and you can connect an additional Auxiliary electrode through the Micro USB port. By default, muse-js does not read data from the Auxiliary electrode channel. You can change this behavior and enable the Auxiliary electrode by setting the `enableAux` property to `true`, just before calling the `connect` method: 70 | 71 | ```javascript 72 | async function main() { 73 | let client = new MuseClient(); 74 | client.enableAux = true; 75 | await client.connect(); 76 | } 77 | ``` 78 | 79 | ## PPG (Photoplethysmography) / Optical Sensor 80 | 81 | The Muse 2 and Muse S contain PPG/optical blood sensors, which this library supports. There are three signal streams, ppg1, ppg2, and ppg3. These are ambient, infrared, and red (respectively) on the Muse 2, and (we think, unconfirmed) infrared, green, and unknown (respectively) on the Muse S. To use PPG, ensure you enable it before connecting to a Muse. PPG is not present and thus will not work on Muse 1/1.5, and enabling it may have unexpected consequences. 82 | 83 | To enable PPG: 84 | 85 | ```javascript 86 | async function main() { 87 | let client = new MuseClient(); 88 | client.enablePpg = true; 89 | await client.connect(); 90 | } 91 | ``` 92 | 93 | To subscribe and receive values from PPG, it's just like subscribing to EEG (see **Usage Example**): 94 | 95 | ```javascript 96 | client.ppgReadings.subscribe((ppgreading) => { 97 | console.log(ppgreading); 98 | }); 99 | ``` 100 | 101 | ## Event Markers 102 | 103 | For convenience, there is an `eventMarkers` stream included in `MuseClient` that you can use in order to introduce timestamped event markers into your project. Just subscribe to `eventMarkers` and use the `injectMarker` method with the value and optional timestamp of an event to send it through the stream. 104 | 105 | ```javascript 106 | async function main() { 107 | let client = new MuseClient(); 108 | client.eventMarkers.subscribe((event) => { 109 | console.log(event); 110 | }); 111 | client.injectMarker("house") 112 | client.injectMarker("face") 113 | client.injectMarker("dog") 114 | } 115 | ``` 116 | 117 | ## Projects using muse-js 118 | 119 | * [EEGEdu](https://eegedu.com/) - Interactive Brain Playground. [Source code](https://github.com/kylemath/EEGEdu) using React, Polaris and chartjs. 120 | * [EEG Explorer](https://muse-eeg-app.web.app/) - Visual EEG readings from the Muse EEG Headset. [Source code](https://github.com/urish/eeg-explorer) using Angular, Material Design and smoothie charts. 121 | -------------------------------------------------------------------------------- /demo/.gitignore: -------------------------------------------------------------------------------- 1 | dist 2 | -------------------------------------------------------------------------------- /demo/fuse.js: -------------------------------------------------------------------------------- 1 | const { 2 | FuseBox, 3 | SassPlugin, 4 | CSSPlugin, 5 | WebIndexPlugin, 6 | TypeScriptHelpers, 7 | JSONPlugin, 8 | HTMLPlugin 9 | } = require('fuse-box'); 10 | 11 | const fuse = FuseBox.init({ 12 | homeDir: `..`, 13 | output: `dist/$name.js`, 14 | plugins: [ 15 | WebIndexPlugin({ 16 | title: "", 17 | template: "src/index.html" 18 | }), [ 19 | SassPlugin({ outputStyle: 'compressed' }), 20 | CSSPlugin() 21 | ], 22 | TypeScriptHelpers(), 23 | JSONPlugin() 24 | ] 25 | }); 26 | 27 | // setup development sever 28 | fuse.dev({ port: 4445 }); 29 | 30 | // bundle application 31 | fuse.bundle("app") 32 | .sourceMaps(true) 33 | .instructions(" > demo/src/main.ts") 34 | .watch('demo/src/**|lib/**') 35 | .hmr(); 36 | 37 | // run the factory 38 | fuse.run(); 39 | -------------------------------------------------------------------------------- /demo/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | Muse JS Demo App 8 | 29 | 30 | 31 | 32 | 35 | 36 |
37 | Name: unknown 38 | Firmware: unknown, 39 | Hardware version: unknown. 40 |
41 | 42 |
43 | Temperature: unknown, Battery: unknown 44 |
45 | 46 |
47 | Accelerometer: x=?, y=?, z=? 51 |
52 | 53 |
54 |
55 |

Electrode 1

56 | 57 |
58 | 59 |
60 |

Electrode 2

61 | 62 |
63 | 64 |
65 |

Electrode 3

66 | 67 |
68 | 69 |
70 |

Electrode 4

71 | 72 |
73 | 74 |
75 |

Electrode 5

76 | 77 |
78 |
79 | 80 | $bundles 81 | 82 | 83 | -------------------------------------------------------------------------------- /demo/src/main.ts: -------------------------------------------------------------------------------- 1 | // tslint:disable:no-console 2 | 3 | import { channelNames, EEGReading, MuseClient } from './../../src/muse'; 4 | 5 | (window as any).connect = async () => { 6 | const graphTitles = Array.from(document.querySelectorAll('.electrode-item h3')); 7 | const canvases = Array.from(document.querySelectorAll('.electrode-item canvas')) as HTMLCanvasElement[]; 8 | const canvasCtx = canvases.map((canvas) => canvas.getContext('2d')); 9 | 10 | graphTitles.forEach((item, index) => { 11 | item.textContent = channelNames[index]; 12 | }); 13 | 14 | function plot(reading: EEGReading) { 15 | const canvas = canvases[reading.electrode]; 16 | const context = canvasCtx[reading.electrode]; 17 | if (!context) { 18 | return; 19 | } 20 | const width = canvas.width / 12.0; 21 | const height = canvas.height / 2.0; 22 | context.fillStyle = 'green'; 23 | context.clearRect(0, 0, canvas.width, canvas.height); 24 | 25 | for (let i = 0; i < reading.samples.length; i++) { 26 | const sample = reading.samples[i] / 15.; 27 | if (sample > 0) { 28 | context.fillRect(i * 25, height - sample, width, sample); 29 | } else { 30 | context.fillRect(i * 25, height, width, -sample); 31 | } 32 | } 33 | } 34 | 35 | const client = new MuseClient(); 36 | client.connectionStatus.subscribe((status) => { 37 | console.log(status ? 'Connected!' : 'Disconnected'); 38 | }); 39 | 40 | try { 41 | client.enableAux = true; 42 | await client.connect(); 43 | await client.start(); 44 | document.getElementById('headset-name')!.innerText = client.deviceName; 45 | client.eegReadings.subscribe((reading) => { 46 | plot(reading); 47 | }); 48 | client.telemetryData.subscribe((reading) => { 49 | document.getElementById('temperature')!.innerText = reading.temperature.toString() + '℃'; 50 | document.getElementById('batteryLevel')!.innerText = reading.batteryLevel.toFixed(2) + '%'; 51 | }); 52 | client.accelerometerData.subscribe((accel) => { 53 | const normalize = (v: number) => (v / 16384.).toFixed(2) + 'g'; 54 | document.getElementById('accelerometer-x')!.innerText = normalize(accel.samples[2].x); 55 | document.getElementById('accelerometer-y')!.innerText = normalize(accel.samples[2].y); 56 | document.getElementById('accelerometer-z')!.innerText = normalize(accel.samples[2].z); 57 | }); 58 | await client.deviceInfo().then((deviceInfo) => { 59 | document.getElementById('hardware-version')!.innerText = deviceInfo.hw; 60 | document.getElementById('firmware-version')!.innerText = deviceInfo.fw; 61 | }); 62 | } catch (err) { 63 | console.error('Connection failed', err); 64 | } 65 | }; 66 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "muse-js", 3 | "version": "3.3.0", 4 | "description": "Muse 2016 EEG Headset JavaScript Library", 5 | "main": "dist/muse.js", 6 | "typings": "dist/muse.d.ts", 7 | "repository": "https://github.com/urish/muse-js", 8 | "author": "Uri Shaked ", 9 | "license": "MIT", 10 | "scripts": { 11 | "build": "tsc", 12 | "format": "prettier --write src/**.ts **/*.json", 13 | "precommit": "lint-staged", 14 | "prepublish": "npm run build", 15 | "start": "node demo/fuse", 16 | "lint": "tslint src/**/*.ts demo/src/**/*.ts", 17 | "test": "npm run lint && jest", 18 | "test:watch": "jest --watch" 19 | }, 20 | "files": [ 21 | "dist" 22 | ], 23 | "devDependencies": { 24 | "@types/jest": "^22.2.3", 25 | "event-target-shim": "^3.0.0", 26 | "fuse-box": "^2.0.0", 27 | "husky": "^0.14.3", 28 | "jest": "^22.4.3", 29 | "lint-staged": "^7.0.4", 30 | "prettier": "^1.12.1", 31 | "text-encoding": "^0.6.4", 32 | "ts-jest": "^22.4.4", 33 | "tslint": "^5.8.0", 34 | "typescript": "^2.8.3", 35 | "web-bluetooth-mock": "^1.0.2", 36 | "zen-observable": "^0.8.8" 37 | }, 38 | "dependencies": { 39 | "@types/web-bluetooth": "^0.0.2", 40 | "rxjs": "^6.0.0 || ^5.6.0-forward-compat.4" 41 | }, 42 | "jest": { 43 | "moduleFileExtensions": [ 44 | "ts", 45 | "js" 46 | ], 47 | "transform": { 48 | "^.+\\.ts$": "ts-jest" 49 | }, 50 | "testMatch": [ 51 | "**/*.spec.ts" 52 | ], 53 | "testURL": "http://localhost/" 54 | }, 55 | "lint-staged": { 56 | "*.{js,json}": [ 57 | "prettier --write", 58 | "git add" 59 | ], 60 | "*.ts": [ 61 | "prettier --write", 62 | "tslint --fix", 63 | "git add" 64 | ] 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /prettier.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | arrowParens: 'always', 3 | printWidth: 120, 4 | singleQuote: true, 5 | tabWidth: 4, 6 | trailingComma: 'all', 7 | overrides: [ 8 | { 9 | files: '*.json', 10 | options: { 11 | tabWidth: 2, 12 | }, 13 | }, 14 | ], 15 | }; 16 | -------------------------------------------------------------------------------- /src/lib/muse-interfaces.ts: -------------------------------------------------------------------------------- 1 | export interface EEGReading { 2 | index: number; 3 | electrode: number; // 0 to 4 4 | timestamp: number; // milliseconds since epoch 5 | samples: number[]; // 12 samples each time 6 | } 7 | 8 | export interface PPGReading { 9 | index: number; 10 | ppgChannel: number; // 0 to 2 11 | timestamp: number; // milliseconds since epoch 12 | samples: number[]; // 6 samples each time 13 | } 14 | 15 | export interface TelemetryData { 16 | sequenceId: number; 17 | batteryLevel: number; 18 | fuelGaugeVoltage: number; 19 | temperature: number; 20 | } 21 | 22 | export interface XYZ { 23 | x: number; 24 | y: number; 25 | z: number; 26 | } 27 | 28 | export interface AccelerometerData { 29 | sequenceId: number; 30 | samples: XYZ[]; 31 | } 32 | 33 | export interface MuseControlResponse { 34 | rc: number; 35 | [key: string]: string | number; 36 | } 37 | 38 | export interface MuseDeviceInfo extends MuseControlResponse { 39 | ap: string; 40 | bl: string; 41 | bn: number; 42 | fw: string; 43 | hw: string; 44 | pv: number; 45 | sp: string; 46 | tp: string; 47 | } 48 | 49 | export interface EventMarker { 50 | value: string | number; 51 | timestamp: number; 52 | } 53 | 54 | export type GyroscopeData = AccelerometerData; 55 | -------------------------------------------------------------------------------- /src/lib/muse-parse.spec.ts: -------------------------------------------------------------------------------- 1 | import { from } from 'rxjs'; 2 | import { toArray } from 'rxjs/operators'; 3 | 4 | import { 5 | decodeUnsigned12BitData, 6 | decodeUnsigned24BitData, 7 | parseAccelerometer, 8 | parseControl, 9 | parseGyroscope, 10 | parseTelemetry, 11 | } from './muse-parse'; 12 | 13 | describe('parseControl', () => { 14 | it('should correctly parse JSON responses into objects', async () => { 15 | const input = from([ 16 | '{"ap":"headset",', 17 | '"sp":"RevE",', 18 | '"tp":"consumer",', 19 | '"hw":"3.1",', 20 | '"bn":27,', 21 | '"fw":"1.2.13",', 22 | '"bl":"1.2.3",', 23 | '"pv":1,', 24 | '"rc":0}', 25 | '{"rc":0}', 26 | '{"rc":0}', 27 | '{"hn":"Muse-1324",', 28 | '"sn":"2031-TZRW-132', 29 | '4",', 30 | '"ma":"00-55-da-b0-1', 31 | '3-24",', 32 | '"id":"07473435 3231', 33 | '3630 004f003a",', 34 | '"bp":82,', 35 | '"ts":0,', 36 | '"ps":32,', 37 | '"rc":0}{"r', 38 | 'c":0}', 39 | ]); 40 | const results = await parseControl(input) 41 | .pipe(toArray()) 42 | .toPromise(); 43 | expect(results).toEqual([ 44 | { 45 | ap: 'headset', 46 | bl: '1.2.3', 47 | bn: 27, 48 | fw: '1.2.13', 49 | hw: '3.1', 50 | pv: 1, 51 | rc: 0, 52 | sp: 'RevE', 53 | tp: 'consumer', 54 | }, 55 | { rc: 0 }, 56 | { rc: 0 }, 57 | { 58 | bp: 82, 59 | hn: 'Muse-1324', 60 | id: '07473435 32313630 004f003a', 61 | ma: '00-55-da-b0-13-24', 62 | ps: 32, 63 | rc: 0, 64 | sn: '2031-TZRW-1324', 65 | ts: 0, 66 | }, 67 | { rc: 0 }, 68 | ]); 69 | }); 70 | }); 71 | 72 | describe('decodeUnsigned12BitData', () => { 73 | it('should correctly decode 12-bit EEG samples received from muse', () => { 74 | const input = new Uint8Array([87, 33, 192, 82, 73, 6, 106, 242, 49, 64, 88, 153, 128, 66, 254, 44, 119, 157]); 75 | expect(decodeUnsigned12BitData(input)).toEqual([ 76 | 1394, 77 | 448, 78 | 1316, 79 | 2310, 80 | 1711, 81 | 561, 82 | 1029, 83 | 2201, 84 | 2052, 85 | 766, 86 | 711, 87 | 1949, 88 | ]); 89 | }); 90 | }); 91 | 92 | describe('decodeUnsigned24BitData', () => { 93 | it('should correctly decode 24-bit PPG samples received from muse', () => { 94 | const input = new Uint8Array([87, 33, 192, 82, 73, 6, 106, 242, 49, 64, 88, 153, 128, 66, 254, 44, 119, 157]); 95 | expect(decodeUnsigned24BitData(input)).toEqual([5710272, 5392646, 7008817, 4216985, 8405758, 2914205]); 96 | }); 97 | }); 98 | 99 | describe('parseTelemtry', () => { 100 | it('should correctly parse Muse telemetry data', () => { 101 | const input = new DataView( 102 | new Uint8Array([1, 74, 181, 184, 7, 64, 15, 127, 0, 27, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]).buffer, 103 | ); 104 | expect(parseTelemetry(input)).toEqual({ 105 | batteryLevel: 90.859375, 106 | fuelGaugeVoltage: 4083.2000000000003, 107 | sequenceId: 330, 108 | temperature: 27, 109 | }); 110 | }); 111 | }); 112 | 113 | describe('parseAccelerometer', () => { 114 | it('should parse Muse accelerometer data and return (x,y,z) vectors in g units', () => { 115 | const input = new DataView( 116 | new Uint8Array([ 117 | 82, 118 | 109, 119 | 13, 120 | 178, 121 | 13, 122 | 157, 123 | 60, 124 | 115, 125 | 18, 126 | 5, 127 | 13, 128 | 73, 129 | 60, 130 | 53, 131 | 17, 132 | 183, 133 | 17, 134 | 227, 135 | 60, 136 | 143, 137 | ]).buffer, 138 | ); 139 | expect(parseAccelerometer(input)).toEqual({ 140 | samples: [ 141 | { x: 0.2139894112, y: 0.21270767200000001, z: 0.9445197200000001 }, 142 | { x: 0.2815553776, y: 0.20758071520000002, z: 0.9407355376000001 }, 143 | { x: 0.276794632, y: 0.27948018080000003, z: 0.9462287056 }, 144 | ], 145 | sequenceId: 21101, 146 | }); 147 | }); 148 | }); 149 | 150 | describe('parseGyroscope', () => { 151 | it('should parse Muse gyroscope data and return (x,y,z) vectors in deg/second units', () => { 152 | const input = new DataView( 153 | new Uint8Array([1, 109, 5, 12, 0, 157, 0, 115, 5, 5, 0, 73, 0, 53, 5, 183, 0, 227, 0, 143]).buffer, 154 | ); 155 | expect(parseGyroscope(input)).toEqual({ 156 | samples: [ 157 | { x: 9.660025599999999, y: 1.1738575999999998, z: 0.8598319999999999 }, 158 | { x: 9.607688, y: 0.5458064, z: 0.39627039999999997 }, 159 | { x: 10.9385584, y: 1.6972336, z: 1.0691823999999999 }, 160 | ], 161 | sequenceId: 365, 162 | }); 163 | }); 164 | }); 165 | -------------------------------------------------------------------------------- /src/lib/muse-parse.ts: -------------------------------------------------------------------------------- 1 | import { Observable } from 'rxjs'; 2 | import { concatMap, filter, map, scan } from 'rxjs/operators'; 3 | 4 | import { AccelerometerData, EEGReading, GyroscopeData, PPGReading, TelemetryData } from './muse-interfaces'; 5 | 6 | export function parseControl(controlData: Observable) { 7 | return controlData.pipe( 8 | concatMap((data) => data.split('')), 9 | scan((acc, value) => { 10 | if (acc.indexOf('}') >= 0) { 11 | return value; 12 | } else { 13 | return acc + value; 14 | } 15 | }, ''), 16 | filter((value) => value.indexOf('}') >= 0), 17 | map((value) => JSON.parse(value)), 18 | ); 19 | } 20 | 21 | export function decodeUnsigned12BitData(samples: Uint8Array) { 22 | const samples12Bit = []; 23 | // tslint:disable:no-bitwise 24 | for (let i = 0; i < samples.length; i++) { 25 | if (i % 3 === 0) { 26 | samples12Bit.push((samples[i] << 4) | (samples[i + 1] >> 4)); 27 | } else { 28 | samples12Bit.push(((samples[i] & 0xf) << 8) | samples[i + 1]); 29 | i++; 30 | } 31 | } 32 | // tslint:enable:no-bitwise 33 | return samples12Bit; 34 | } 35 | 36 | export function decodeUnsigned24BitData(samples: Uint8Array) { 37 | const samples24Bit = []; 38 | // tslint:disable:no-bitwise 39 | for (let i = 0; i < samples.length; i = i + 3) { 40 | samples24Bit.push((samples[i] << 16) | (samples[i + 1] << 8) | samples[i + 2]); 41 | } 42 | // tslint:enable:no-bitwise 43 | return samples24Bit; 44 | } 45 | 46 | export function decodeEEGSamples(samples: Uint8Array) { 47 | return decodeUnsigned12BitData(samples).map((n) => 0.48828125 * (n - 0x800)); 48 | } 49 | 50 | export function decodePPGSamples(samples: Uint8Array) { 51 | // Decode data packet of one PPG channel. 52 | // Each packet is encoded with a 16bit timestamp followed by 6 53 | // samples with a 24 bit resolution. 54 | return decodeUnsigned24BitData(samples); 55 | } 56 | 57 | export function parseTelemetry(data: DataView): TelemetryData { 58 | // tslint:disable:object-literal-sort-keys 59 | return { 60 | sequenceId: data.getUint16(0), 61 | batteryLevel: data.getUint16(2) / 512, 62 | fuelGaugeVoltage: data.getUint16(4) * 2.2, 63 | // Next 2 bytes are probably ADC millivolt level, not sure 64 | temperature: data.getUint16(8), 65 | }; 66 | // tslint:enable:object-literal-sort-keys 67 | } 68 | 69 | function parseImuReading(data: DataView, scale: number) { 70 | function sample(startIndex: number) { 71 | return { 72 | x: scale * data.getInt16(startIndex), 73 | y: scale * data.getInt16(startIndex + 2), 74 | z: scale * data.getInt16(startIndex + 4), 75 | }; 76 | } 77 | // tslint:disable:object-literal-sort-keys 78 | return { 79 | sequenceId: data.getUint16(0), 80 | samples: [sample(2), sample(8), sample(14)], 81 | }; 82 | // tslint:enable:object-literal-sort-keys 83 | } 84 | 85 | export function parseAccelerometer(data: DataView): AccelerometerData { 86 | return parseImuReading(data, 0.0000610352); 87 | } 88 | 89 | export function parseGyroscope(data: DataView): GyroscopeData { 90 | return parseImuReading(data, 0.0074768); 91 | } 92 | -------------------------------------------------------------------------------- /src/lib/muse-utils.spec.ts: -------------------------------------------------------------------------------- 1 | import { encodeCommand } from './muse-utils'; 2 | 3 | import { TextDecoder, TextEncoder } from 'text-encoding'; // polyfill 4 | 5 | declare var global: any; 6 | global.TextEncoder = TextEncoder; 7 | global.TextDecoder = TextDecoder; 8 | 9 | describe('encodeCommand', () => { 10 | it('should correctly encode the given command as a Uint8Array', () => { 11 | expect(encodeCommand('v1')).toEqual(new Uint8Array([3, 118, 49, 10])); 12 | }); 13 | }); 14 | -------------------------------------------------------------------------------- /src/lib/muse-utils.ts: -------------------------------------------------------------------------------- 1 | import { fromEvent, Observable } from 'rxjs'; 2 | import { map, takeUntil } from 'rxjs/operators'; 3 | 4 | export function decodeResponse(bytes: Uint8Array) { 5 | return new TextDecoder().decode(bytes.subarray(1, 1 + bytes[0])); 6 | } 7 | 8 | export function encodeCommand(cmd: string) { 9 | const encoded = new TextEncoder().encode(`X${cmd}\n`); 10 | encoded[0] = encoded.length - 1; 11 | return encoded; 12 | } 13 | 14 | export async function observableCharacteristic(characteristic: BluetoothRemoteGATTCharacteristic) { 15 | await characteristic.startNotifications(); 16 | const disconnected = fromEvent(characteristic.service!.device, 'gattserverdisconnected'); 17 | return fromEvent(characteristic, 'characteristicvaluechanged').pipe( 18 | takeUntil(disconnected), 19 | map((event: Event) => (event.target as BluetoothRemoteGATTCharacteristic).value as DataView), 20 | ); 21 | } 22 | -------------------------------------------------------------------------------- /src/lib/zip-samples.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { toArray } from 'rxjs/operators'; 3 | 4 | import { zipSamples } from './zip-samples'; 5 | 6 | // tslint:disable:object-literal-sort-keys 7 | 8 | describe('zipSamples', () => { 9 | it('should zip all eeg channels into one array', async () => { 10 | const input = of( 11 | { 12 | electrode: 2, 13 | index: 100, 14 | timestamp: 1000, 15 | samples: [2.01, 2.02, 2.03, 2.04, 2.05, 2.06, 2.07, 2.08, 2.09, 2.1, 2.11, 2.12], 16 | }, 17 | { 18 | electrode: 1, 19 | index: 100, 20 | timestamp: 1000, 21 | samples: [1.01, 1.02, 1.03, 1.04, 1.05, 1.06, 1.07, 1.08, 1.09, 1.1, 1.11, 1.12], 22 | }, 23 | { 24 | electrode: 4, 25 | index: 100, 26 | timestamp: 1000, 27 | samples: [4.01, 4.02, 4.03, 4.04, 4.05, 4.06, 4.07, 4.08, 4.09, 4.1, 4.11, 4.12], 28 | }, 29 | { 30 | electrode: 0, 31 | index: 100, 32 | timestamp: 1000, 33 | samples: [0.01, 0.02, 0.03, 0.04, 0.05, 0.06, 0.07, 0.08, 0.09, 0.1, 0.11, 0.12], 34 | }, 35 | { 36 | electrode: 3, 37 | index: 100, 38 | timestamp: 1000, 39 | samples: [3.01, 3.02, 3.03, 3.04, 3.05, 3.06, 3.07, 3.08, 3.09, 3.1, 3.11, 3.12], 40 | }, 41 | { 42 | electrode: 2, 43 | index: 101, 44 | timestamp: 1046.875, 45 | samples: [12.01, 12.02, 12.03, 12.04, 12.05, 12.06, 12.07, 12.08, 12.09, 12.1, 12.11, 12.12], 46 | }, 47 | { 48 | electrode: 1, 49 | index: 101, 50 | timestamp: 1046.875, 51 | samples: [11.01, 11.02, 11.03, 11.04, 11.05, 11.06, 11.07, 11.08, 11.09, 11.1, 11.11, 11.12], 52 | }, 53 | { 54 | electrode: 4, 55 | index: 101, 56 | timestamp: 1046.875, 57 | samples: [14.01, 14.02, 14.03, 14.04, 14.05, 14.06, 14.07, 14.08, 14.09, 14.1, 14.11, 14.12], 58 | }, 59 | { 60 | electrode: 0, 61 | index: 101, 62 | timestamp: 1046.875, 63 | samples: [10.01, 10.02, 10.03, 10.04, 10.05, 10.06, 10.07, 10.08, 10.09, 10.1, 10.11, 10.12], 64 | }, 65 | { 66 | electrode: 3, 67 | index: 101, 68 | timestamp: 1046.875, 69 | samples: [13.01, 13.02, 13.03, 13.04, 13.05, 13.06, 13.07, 13.08, 13.09, 13.1, 13.11, 13.12], 70 | }, 71 | ); 72 | const zipped = zipSamples(input); 73 | const result = await zipped.pipe(toArray()).toPromise(); 74 | expect(result).toEqual([ 75 | { index: 100, timestamp: 1000.0, data: [0.01, 1.01, 2.01, 3.01, 4.01] }, 76 | { index: 100, timestamp: 1003.90625, data: [0.02, 1.02, 2.02, 3.02, 4.02] }, 77 | { index: 100, timestamp: 1007.8125, data: [0.03, 1.03, 2.03, 3.03, 4.03] }, 78 | { index: 100, timestamp: 1011.71875, data: [0.04, 1.04, 2.04, 3.04, 4.04] }, 79 | { index: 100, timestamp: 1015.625, data: [0.05, 1.05, 2.05, 3.05, 4.05] }, 80 | { index: 100, timestamp: 1019.53125, data: [0.06, 1.06, 2.06, 3.06, 4.06] }, 81 | { index: 100, timestamp: 1023.4375, data: [0.07, 1.07, 2.07, 3.07, 4.07] }, 82 | { index: 100, timestamp: 1027.34375, data: [0.08, 1.08, 2.08, 3.08, 4.08] }, 83 | { index: 100, timestamp: 1031.25, data: [0.09, 1.09, 2.09, 3.09, 4.09] }, 84 | { index: 100, timestamp: 1035.15625, data: [0.1, 1.1, 2.1, 3.1, 4.1] }, 85 | { index: 100, timestamp: 1039.0625, data: [0.11, 1.11, 2.11, 3.11, 4.11] }, 86 | { index: 100, timestamp: 1042.96875, data: [0.12, 1.12, 2.12, 3.12, 4.12] }, 87 | { index: 101, timestamp: 1046.875, data: [10.01, 11.01, 12.01, 13.01, 14.01] }, 88 | { index: 101, timestamp: 1050.78125, data: [10.02, 11.02, 12.02, 13.02, 14.02] }, 89 | { index: 101, timestamp: 1054.6875, data: [10.03, 11.03, 12.03, 13.03, 14.03] }, 90 | { index: 101, timestamp: 1058.59375, data: [10.04, 11.04, 12.04, 13.04, 14.04] }, 91 | { index: 101, timestamp: 1062.5, data: [10.05, 11.05, 12.05, 13.05, 14.05] }, 92 | { index: 101, timestamp: 1066.40625, data: [10.06, 11.06, 12.06, 13.06, 14.06] }, 93 | { index: 101, timestamp: 1070.3125, data: [10.07, 11.07, 12.07, 13.07, 14.07] }, 94 | { index: 101, timestamp: 1074.21875, data: [10.08, 11.08, 12.08, 13.08, 14.08] }, 95 | { index: 101, timestamp: 1078.125, data: [10.09, 11.09, 12.09, 13.09, 14.09] }, 96 | { index: 101, timestamp: 1082.03125, data: [10.1, 11.1, 12.1, 13.1, 14.1] }, 97 | { index: 101, timestamp: 1085.9375, data: [10.11, 11.11, 12.11, 13.11, 14.11] }, 98 | { index: 101, timestamp: 1089.84375, data: [10.12, 11.12, 12.12, 13.12, 14.12] }, 99 | ]); 100 | }); 101 | 102 | it('should indicate missing samples with NaN', async () => { 103 | const input = of( 104 | { index: 50, timestamp: 5000, electrode: 2, samples: [2.01, 2.02, 2.03, 2.04] }, 105 | { index: 50, timestamp: 5000, electrode: 4, samples: [4.01, 4.02, 4.03, 4.04] }, 106 | { index: 50, timestamp: 5000, electrode: 0, samples: [0.01, 0.02, 0.03, 0.04] }, 107 | { index: 50, timestamp: 5000, electrode: 3, samples: [3.01, 3.02, 3.03, 3.04] }, 108 | ); 109 | const zipped = zipSamples(input); 110 | const result = await zipped.pipe(toArray()).toPromise(); 111 | expect(result).toEqual([ 112 | { index: 50, timestamp: 5000.0, data: [0.01, NaN, 2.01, 3.01, 4.01] }, 113 | { index: 50, timestamp: 5003.90625, data: [0.02, NaN, 2.02, 3.02, 4.02] }, 114 | { index: 50, timestamp: 5007.8125, data: [0.03, NaN, 2.03, 3.03, 4.03] }, 115 | { index: 50, timestamp: 5011.71875, data: [0.04, NaN, 2.04, 3.04, 4.04] }, 116 | ]); 117 | }); 118 | }); 119 | -------------------------------------------------------------------------------- /src/lib/zip-samples.ts: -------------------------------------------------------------------------------- 1 | import { from, Observable } from 'rxjs'; 2 | import { concat, mergeMap } from 'rxjs/operators'; 3 | import { EEG_FREQUENCY } from './../muse'; 4 | import { EEGReading } from './muse-interfaces'; 5 | 6 | export interface EEGSample { 7 | index: number; 8 | timestamp: number; // milliseconds since epoch 9 | data: number[]; 10 | } 11 | 12 | export function zipSamples(eegReadings: Observable): Observable { 13 | const buffer: EEGReading[] = []; 14 | let lastTimestamp: number | null = null; 15 | return eegReadings.pipe( 16 | mergeMap((reading) => { 17 | if (reading.timestamp !== lastTimestamp) { 18 | lastTimestamp = reading.timestamp; 19 | if (buffer.length) { 20 | const result = from([[...buffer]]); 21 | buffer.splice(0, buffer.length, reading); 22 | return result; 23 | } 24 | } 25 | buffer.push(reading); 26 | return from([]); 27 | }), 28 | concat(from([buffer])), 29 | mergeMap((readings: EEGReading[]) => { 30 | const result = readings[0].samples.map((x, index) => { 31 | const data = [NaN, NaN, NaN, NaN, NaN]; 32 | for (const reading of readings) { 33 | data[reading.electrode] = reading.samples[index]; 34 | } 35 | return { 36 | data, 37 | index: readings[0].index, 38 | timestamp: readings[0].timestamp + (index * 1000) / EEG_FREQUENCY, 39 | }; 40 | }); 41 | return from(result); 42 | }), 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/lib/zip-samplesPpg.spec.ts: -------------------------------------------------------------------------------- 1 | import { Observable, of } from 'rxjs'; 2 | import { toArray } from 'rxjs/operators'; 3 | 4 | import { zipSamplesPpg } from './zip-samplesPpg'; 5 | 6 | // tslint:disable:object-literal-sort-keys 7 | 8 | describe('zipSamplesPpg', () => { 9 | it('should zip all ppg channels into one array', async () => { 10 | const input = of( 11 | { 12 | ppgChannel: 0, 13 | index: 100, 14 | timestamp: 1000, 15 | samples: [0.01, 0.02, 0.03, 0.04, 0.05, 0.06], 16 | }, 17 | { 18 | ppgChannel: 1, 19 | index: 100, 20 | timestamp: 1000, 21 | samples: [1.01, 1.02, 1.03, 1.04, 1.05, 1.06], 22 | }, 23 | { 24 | ppgChannel: 2, 25 | index: 100, 26 | timestamp: 1000, 27 | samples: [2.01, 2.02, 2.03, 2.04, 2.05, 2.06], 28 | }, 29 | { 30 | ppgChannel: 0, 31 | index: 101, 32 | timestamp: 1046.875, 33 | samples: [10.01, 10.02, 10.03, 10.04, 10.05, 10.06], 34 | }, 35 | { 36 | ppgChannel: 1, 37 | index: 101, 38 | timestamp: 1046.875, 39 | samples: [11.01, 11.02, 11.03, 11.04, 11.05, 11.06], 40 | }, 41 | { 42 | ppgChannel: 2, 43 | index: 101, 44 | timestamp: 1046.875, 45 | samples: [12.01, 12.02, 12.03, 12.04, 12.05, 12.06], 46 | }, 47 | ); 48 | const zipped = zipSamplesPpg(input); 49 | const result = await zipped.pipe(toArray()).toPromise(); 50 | expect(result).toEqual([ 51 | { index: 100, timestamp: 1000.0, data: [0.01, 1.01, 2.01] }, 52 | { index: 100, timestamp: 1015.625, data: [0.02, 1.02, 2.02] }, 53 | { index: 100, timestamp: 1031.25, data: [0.03, 1.03, 2.03] }, 54 | { index: 100, timestamp: 1046.875, data: [0.04, 1.04, 2.04] }, 55 | { index: 100, timestamp: 1062.5, data: [0.05, 1.05, 2.05] }, 56 | { index: 100, timestamp: 1078.125, data: [0.06, 1.06, 2.06] }, 57 | { index: 101, timestamp: 1046.875, data: [10.01, 11.01, 12.01] }, 58 | { index: 101, timestamp: 1062.5, data: [10.02, 11.02, 12.02] }, 59 | { index: 101, timestamp: 1078.125, data: [10.03, 11.03, 12.03] }, 60 | { index: 101, timestamp: 1093.75, data: [10.04, 11.04, 12.04] }, 61 | { index: 101, timestamp: 1109.375, data: [10.05, 11.05, 12.05] }, 62 | { index: 101, timestamp: 1125, data: [10.06, 11.06, 12.06] }, 63 | ]); 64 | }); 65 | 66 | it('should indicate missing samples with NaN', async () => { 67 | const input = of( 68 | { index: 50, timestamp: 5000, ppgChannel: 0, samples: [0.01, 0.02, 0.03, 0.04] }, 69 | { index: 50, timestamp: 5000, ppgChannel: 2, samples: [2.01, 2.02, 2.03, 2.04] }, 70 | ); 71 | const zipped = zipSamplesPpg(input); 72 | const result = await zipped.pipe(toArray()).toPromise(); 73 | expect(result).toEqual([ 74 | { index: 50, timestamp: 5000.0, data: [0.01, NaN, 2.01] }, 75 | { index: 50, timestamp: 5015.625, data: [0.02, NaN, 2.02] }, 76 | { index: 50, timestamp: 5031.25, data: [0.03, NaN, 2.03] }, 77 | { index: 50, timestamp: 5046.875, data: [0.04, NaN, 2.04] }, 78 | ]); 79 | }); 80 | }); 81 | -------------------------------------------------------------------------------- /src/lib/zip-samplesPpg.ts: -------------------------------------------------------------------------------- 1 | import { from, Observable } from 'rxjs'; 2 | import { concat, mergeMap } from 'rxjs/operators'; 3 | import { PPG_FREQUENCY } from './../muse'; 4 | import { PPGReading } from './muse-interfaces'; 5 | 6 | export interface PPGSample { 7 | index: number; 8 | timestamp: number; // milliseconds since epoch 9 | data: number[]; 10 | } 11 | 12 | export function zipSamplesPpg(ppgReadings: Observable): Observable { 13 | const buffer: PPGReading[] = []; 14 | let lastTimestamp: number | null = null; 15 | return ppgReadings.pipe( 16 | mergeMap((reading) => { 17 | if (reading.timestamp !== lastTimestamp) { 18 | lastTimestamp = reading.timestamp; 19 | if (buffer.length) { 20 | const result = from([[...buffer]]); 21 | buffer.splice(0, buffer.length, reading); 22 | return result; 23 | } 24 | } 25 | buffer.push(reading); 26 | return from([]); 27 | }), 28 | concat(from([buffer])), 29 | mergeMap((readings: PPGReading[]) => { 30 | const result = readings[0].samples.map((x, index) => { 31 | const data = [NaN, NaN, NaN]; 32 | for (const reading of readings) { 33 | data[reading.ppgChannel] = reading.samples[index]; 34 | } 35 | return { 36 | data, 37 | index: readings[0].index, 38 | timestamp: readings[0].timestamp + (index * 1000) / PPG_FREQUENCY, 39 | }; 40 | }); 41 | return from(result); 42 | }), 43 | ); 44 | } 45 | -------------------------------------------------------------------------------- /src/muse.spec.ts: -------------------------------------------------------------------------------- 1 | import { TextDecoder, TextEncoder } from 'text-encoding'; 2 | import { DeviceMock, WebBluetoothMock } from 'web-bluetooth-mock'; 3 | import { EEGReading, PPGReading } from './../dist/lib/muse-interfaces.d'; 4 | import { MuseClient } from './muse'; 5 | 6 | declare var global; 7 | 8 | let museDevice: DeviceMock; 9 | 10 | function charCodes(s) { 11 | return s.split('').map((c) => c.charCodeAt(0)); 12 | } 13 | 14 | describe('MuseClient', () => { 15 | beforeEach(() => { 16 | museDevice = new DeviceMock('Muse-Test', [0xfe8d]); 17 | global.navigator = global.navigator || {}; 18 | global.navigator.bluetooth = new WebBluetoothMock([museDevice]); 19 | Object.assign(global, { TextDecoder, TextEncoder }); 20 | }); 21 | 22 | describe('connect', () => { 23 | it('should connect to EEG headset', async () => { 24 | const client = new MuseClient(); 25 | await client.connect(); 26 | }); 27 | 28 | it('should call startNotifications() on the EEG electrode characteritics', async () => { 29 | const service = museDevice.getServiceMock(0xfe8d); 30 | const eeg1Char = service.getCharacteristicMock('273e0003-4c4d-454d-96be-f03bac821358'); 31 | eeg1Char.startNotifications = jest.fn(); 32 | 33 | const client = new MuseClient(); 34 | await client.connect(); 35 | 36 | expect(eeg1Char.startNotifications).toHaveBeenCalled(); 37 | }); 38 | 39 | it('should call startNotifications() on the PPG channel characteritics', async () => { 40 | const service = museDevice.getServiceMock(0xfe8d); 41 | const ppg1Char = service.getCharacteristicMock('273e000f-4c4d-454d-96be-f03bac821358'); 42 | ppg1Char.startNotifications = jest.fn(); 43 | 44 | const client = new MuseClient(); 45 | client.enablePpg = true; 46 | await client.connect(); 47 | 48 | expect(ppg1Char.startNotifications).toHaveBeenCalled(); 49 | }); 50 | 51 | it('should not call startNotifications() on the Aux EEG electrode by default', async () => { 52 | const service = museDevice.getServiceMock(0xfe8d); 53 | const eegAuxChar = service.getCharacteristicMock('273e0007-4c4d-454d-96be-f03bac821358'); 54 | eegAuxChar.startNotifications = jest.fn(); 55 | 56 | const client = new MuseClient(); 57 | await client.connect(); 58 | 59 | expect(eegAuxChar.startNotifications).not.toHaveBeenCalled(); 60 | }); 61 | 62 | it('should call startNotifications() on the Aux EEG electrode when enableAux is set to true', async () => { 63 | const service = museDevice.getServiceMock(0xfe8d); 64 | const eegAuxChar = service.getCharacteristicMock('273e0007-4c4d-454d-96be-f03bac821358'); 65 | eegAuxChar.startNotifications = jest.fn(); 66 | 67 | const client = new MuseClient(); 68 | client.enableAux = true; 69 | await client.connect(); 70 | 71 | expect(eegAuxChar.startNotifications).toHaveBeenCalled(); 72 | }); 73 | }); 74 | 75 | describe('start', async () => { 76 | it('should send `h`, `s`, `p21` and `d` commands to the EEG headset', async () => { 77 | const client = new MuseClient(); 78 | const controlCharacteristic = museDevice 79 | .getServiceMock(0xfe8d) 80 | .getCharacteristicMock('273e0001-4c4d-454d-96be-f03bac821358'); 81 | controlCharacteristic.writeValue = jest.fn(); 82 | 83 | await client.connect(); 84 | await client.start(); 85 | 86 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([2, ...charCodes('h'), 10])); 87 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([2, ...charCodes('s'), 10])); 88 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([4, ...charCodes('p21'), 10])); 89 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([2, ...charCodes('d'), 10])); 90 | }); 91 | 92 | it('choose preset number 20 instead of 21 if aux is enabled', async () => { 93 | const client = new MuseClient(); 94 | const controlCharacteristic = museDevice 95 | .getServiceMock(0xfe8d) 96 | .getCharacteristicMock('273e0001-4c4d-454d-96be-f03bac821358'); 97 | controlCharacteristic.writeValue = jest.fn(); 98 | 99 | client.enableAux = true; 100 | await client.connect(); 101 | await client.start(); 102 | 103 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([4, ...charCodes('p20'), 10])); 104 | expect(controlCharacteristic.writeValue).not.toHaveBeenCalledWith( 105 | new Uint8Array([4, ...charCodes('p21'), 10]), 106 | ); 107 | }); 108 | 109 | it('choose preset number 50 instead of 20/21 if ppg is enabled', async () => { 110 | const client = new MuseClient(); 111 | const controlCharacteristic = museDevice 112 | .getServiceMock(0xfe8d) 113 | .getCharacteristicMock('273e0001-4c4d-454d-96be-f03bac821358'); 114 | controlCharacteristic.writeValue = jest.fn(); 115 | 116 | client.enableAux = true; 117 | client.enablePpg = true; 118 | await client.connect(); 119 | await client.start(); 120 | 121 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([4, ...charCodes('p50'), 10])); 122 | expect(controlCharacteristic.writeValue).not.toHaveBeenCalledWith( 123 | new Uint8Array([4, ...charCodes('p21'), 10]), 124 | ); 125 | }); 126 | }); 127 | 128 | describe('eegReadings', () => { 129 | it('should emit a value for `eegReadings` observable whenever new EEG data is received', async () => { 130 | const service = museDevice.getServiceMock(0xfe8d); 131 | const eeg3Char = service.getCharacteristicMock('273e0006-4c4d-454d-96be-f03bac821358'); 132 | 133 | const client = new MuseClient(); 134 | await client.connect(); 135 | 136 | let lastReading: EEGReading; 137 | client.eegReadings.subscribe((reading) => { 138 | lastReading = reading; 139 | }); 140 | 141 | eeg3Char.value = new DataView( 142 | new Uint8Array([0, 1, 40, 3, 128, 40, 3, 128, 40, 3, 128, 40, 3, 128, 40, 3, 128, 40, 3, 128]).buffer, 143 | ); 144 | const beforeDispatchTime = new Date().getTime(); 145 | eeg3Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 146 | const afterDispatchTime = new Date().getTime(); 147 | 148 | expect(lastReading).toEqual({ 149 | electrode: 3, 150 | index: 1, 151 | samples: [ 152 | -687.5, 153 | -562.5, 154 | -687.5, 155 | -562.5, 156 | -687.5, 157 | -562.5, 158 | -687.5, 159 | -562.5, 160 | -687.5, 161 | -562.5, 162 | -687.5, 163 | -562.5, 164 | ], 165 | timestamp: expect.any(Number), 166 | }); 167 | 168 | // Timestamp should be about (1000/256.0*12) milliseconds behind the event dispatch time 169 | expect(lastReading.timestamp).toBeGreaterThanOrEqual(beforeDispatchTime - (1000 / 256.0) * 12); 170 | expect(lastReading.timestamp).toBeLessThanOrEqual(afterDispatchTime - (1000 / 256.0) * 12); 171 | }); 172 | 173 | it('should report the same timestamp for eeg events with the same sequence', async () => { 174 | const service = museDevice.getServiceMock(0xfe8d); 175 | const eeg1Char = service.getCharacteristicMock('273e0004-4c4d-454d-96be-f03bac821358'); 176 | const eeg3Char = service.getCharacteristicMock('273e0006-4c4d-454d-96be-f03bac821358'); 177 | 178 | const client = new MuseClient(); 179 | await client.connect(); 180 | 181 | const readings: EEGReading[] = []; 182 | client.eegReadings.subscribe((reading) => { 183 | readings.push(reading); 184 | }); 185 | 186 | eeg1Char.value = new DataView(new Uint8Array([0, 15]).buffer); 187 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 188 | eeg3Char.value = new DataView(new Uint8Array([0, 15]).buffer); 189 | eeg3Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 190 | 191 | expect(readings.length).toBe(2); 192 | expect(readings[0].electrode).toBe(1); 193 | expect(readings[1].electrode).toBe(3); 194 | expect(readings[0].timestamp).toEqual(readings[1].timestamp); 195 | }); 196 | 197 | it('should bump the timestamp for subsequent EEG events', async () => { 198 | const service = museDevice.getServiceMock(0xfe8d); 199 | const eeg1Char = service.getCharacteristicMock('273e0004-4c4d-454d-96be-f03bac821358'); 200 | 201 | const client = new MuseClient(); 202 | await client.connect(); 203 | 204 | const readings: EEGReading[] = []; 205 | client.eegReadings.subscribe((reading) => { 206 | readings.push(reading); 207 | }); 208 | 209 | eeg1Char.value = new DataView(new Uint8Array([0, 15]).buffer); 210 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 211 | eeg1Char.value = new DataView(new Uint8Array([0, 16]).buffer); 212 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 213 | 214 | expect(readings[1].timestamp - readings[0].timestamp).toEqual(1000 / (256.0 / 12.0)); 215 | }); 216 | 217 | it('should correctly handle out-of-order EEG events', async () => { 218 | const service = museDevice.getServiceMock(0xfe8d); 219 | const eeg1Char = service.getCharacteristicMock('273e0004-4c4d-454d-96be-f03bac821358'); 220 | 221 | const client = new MuseClient(); 222 | await client.connect(); 223 | 224 | const readings: EEGReading[] = []; 225 | client.eegReadings.subscribe((reading) => { 226 | readings.push(reading); 227 | }); 228 | 229 | eeg1Char.value = new DataView(new Uint8Array([0, 20]).buffer); 230 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 231 | eeg1Char.value = new DataView(new Uint8Array([0, 16]).buffer); 232 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 233 | 234 | expect(readings[1].timestamp - readings[0].timestamp).toEqual((-4 * 1000) / (256.0 / 12.0)); 235 | }); 236 | 237 | it('should handle timestamp wraparound', async () => { 238 | const service = museDevice.getServiceMock(0xfe8d); 239 | const eeg1Char = service.getCharacteristicMock('273e0004-4c4d-454d-96be-f03bac821358'); 240 | 241 | const client = new MuseClient(); 242 | await client.connect(); 243 | 244 | const readings: EEGReading[] = []; 245 | client.eegReadings.subscribe((reading) => { 246 | readings.push(reading); 247 | }); 248 | 249 | eeg1Char.value = new DataView(new Uint8Array([0xff, 0xff]).buffer); 250 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 251 | eeg1Char.value = new DataView(new Uint8Array([0, 0]).buffer); 252 | eeg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 253 | 254 | expect(readings[1].timestamp - readings[0].timestamp).toEqual(1000 / (256.0 / 12.0)); 255 | }); 256 | }); 257 | 258 | describe('ppgReadings', () => { 259 | it('should report the same timestamp for ppg events with the same sequence', async () => { 260 | const service = museDevice.getServiceMock(0xfe8d); 261 | const ppg0Char = service.getCharacteristicMock('273e000f-4c4d-454d-96be-f03bac821358'); 262 | const ppg1Char = service.getCharacteristicMock('273e0010-4c4d-454d-96be-f03bac821358'); 263 | 264 | const client = new MuseClient(); 265 | client.enablePpg = true; 266 | await client.connect(); 267 | 268 | const readings: PPGReading[] = []; 269 | client.ppgReadings.subscribe((reading) => { 270 | readings.push(reading); 271 | }); 272 | 273 | ppg0Char.value = new DataView(new Uint8Array([0, 15]).buffer); 274 | ppg0Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 275 | ppg1Char.value = new DataView(new Uint8Array([0, 15]).buffer); 276 | ppg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 277 | 278 | expect(readings.length).toBe(2); 279 | expect(readings[0].ppgChannel).toBe(0); 280 | expect(readings[1].ppgChannel).toBe(1); 281 | expect(readings[0].timestamp).toEqual(readings[1].timestamp); 282 | }); 283 | 284 | it('should bump the timestamp for subsequent PPG events', async () => { 285 | const service = museDevice.getServiceMock(0xfe8d); 286 | const ppg0Char = service.getCharacteristicMock('273e000f-4c4d-454d-96be-f03bac821358'); 287 | 288 | const client = new MuseClient(); 289 | client.enableAux = true; 290 | client.enablePpg = true; 291 | await client.connect(); 292 | 293 | const readings: PPGReading[] = []; 294 | client.ppgReadings.subscribe((reading) => { 295 | readings.push(reading); 296 | }); 297 | 298 | ppg0Char.value = new DataView(new Uint8Array([0, 15]).buffer); 299 | ppg0Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 300 | ppg0Char.value = new DataView(new Uint8Array([0, 16]).buffer); 301 | ppg0Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 302 | 303 | expect(readings[1].timestamp - readings[0].timestamp).toEqual(1000 / (64.0 / 6.0)); 304 | }); 305 | 306 | it('should correctly handle out-of-order PPG events', async () => { 307 | const service = museDevice.getServiceMock(0xfe8d); 308 | const ppg1Char = service.getCharacteristicMock('273e0010-4c4d-454d-96be-f03bac821358'); 309 | 310 | const client = new MuseClient(); 311 | client.enableAux = true; 312 | client.enablePpg = true; 313 | await client.connect(); 314 | 315 | const readings: PPGReading[] = []; 316 | client.ppgReadings.subscribe((reading) => { 317 | readings.push(reading); 318 | }); 319 | 320 | ppg1Char.value = new DataView(new Uint8Array([0, 20]).buffer); 321 | ppg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 322 | ppg1Char.value = new DataView(new Uint8Array([0, 16]).buffer); 323 | ppg1Char.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 324 | 325 | expect(readings[1].timestamp - readings[0].timestamp).toEqual((-4 * 1000) / (64.0 / 6.0)); 326 | }); 327 | }); 328 | 329 | describe('deviceInfo', () => { 330 | it('should return information about the headset', async () => { 331 | const service = museDevice.getServiceMock(0xfe8d); 332 | const controlCharacteristic = service.getCharacteristicMock('273e0001-4c4d-454d-96be-f03bac821358'); 333 | jest.spyOn(controlCharacteristic, 'writeValue'); 334 | 335 | const client = new MuseClient(); 336 | await client.connect(); 337 | const deviceInfoPromise = client.deviceInfo(); 338 | 339 | expect(controlCharacteristic.writeValue).toHaveBeenCalledWith(new Uint8Array([3, ...charCodes('v1'), 10])); 340 | 341 | const deviceResponse = [ 342 | [16, 123, 34, 97, 112, 34, 58, 34, 104, 101, 97, 100, 115, 101, 116, 34, 44, 50, 51, 49], 343 | [12, 34, 115, 112, 34, 58, 34, 82, 101, 118, 69, 34, 44, 101, 116, 34, 44, 50, 51, 49], 344 | [16, 34, 116, 112, 34, 58, 34, 99, 111, 110, 115, 117, 109, 101, 114, 34, 44, 50, 51, 49], 345 | [11, 34, 104, 119, 34, 58, 34, 51, 46, 49, 34, 44, 109, 101, 114, 34, 44, 50, 51, 49], 346 | [8, 34, 98, 110, 34, 58, 50, 55, 44, 49, 34, 44, 109, 101, 114, 34, 44, 50, 51, 49], 347 | [14, 34, 102, 119, 34, 58, 34, 49, 46, 50, 46, 49, 51, 34, 44, 34, 44, 50, 51, 49], 348 | [13, 34, 98, 108, 34, 58, 34, 49, 46, 50, 46, 51, 34, 44, 44, 34, 44, 50, 51, 49], 349 | [7, 34, 112, 118, 34, 58, 49, 44, 46, 50, 46, 51, 34, 44, 44, 34, 44, 50, 51, 49], 350 | [7, 34, 114, 99, 34, 58, 48, 125, 46, 50, 46, 51, 34, 44, 44, 34, 44, 50, 51, 49], 351 | ]; 352 | 353 | deviceResponse.forEach((data) => { 354 | controlCharacteristic.value = new DataView(new Uint8Array(data).buffer); 355 | controlCharacteristic.dispatchEvent(new CustomEvent('characteristicvaluechanged')); 356 | }); 357 | 358 | const deviceInfo = await deviceInfoPromise; 359 | expect(deviceInfo).toEqual({ 360 | ap: 'headset', 361 | bl: '1.2.3', 362 | bn: 27, 363 | fw: '1.2.13', 364 | hw: '3.1', 365 | pv: 1, 366 | rc: 0, 367 | sp: 'RevE', 368 | tp: 'consumer', 369 | }); 370 | }); 371 | }); 372 | 373 | describe('disconnect', () => { 374 | it('should disconnect from gatt', async () => { 375 | const client = new MuseClient(); 376 | await client.connect(); 377 | 378 | jest.spyOn(museDevice.gatt, 'disconnect'); 379 | client.disconnect(); 380 | expect(museDevice.gatt.disconnect).toHaveBeenCalled(); 381 | }); 382 | 383 | it('should emit a disconnect event', async () => { 384 | const client = new MuseClient(); 385 | let lastStatus = null; 386 | client.connectionStatus.subscribe((value) => { 387 | lastStatus = value; 388 | }); 389 | await client.connect(); 390 | expect(lastStatus).toBe(true); 391 | client.disconnect(); 392 | expect(lastStatus).toBe(false); 393 | }); 394 | 395 | it('should silently return if connect() was not valled', async () => { 396 | const client = new MuseClient(); 397 | client.disconnect(); 398 | }); 399 | }); 400 | 401 | describe('eventMarkers', () => { 402 | it('should emit a marker whenever injectMarker is called', async () => { 403 | const client = new MuseClient(); 404 | await client.connect(); 405 | 406 | const markers = []; 407 | client.eventMarkers.subscribe((eventMarker) => { 408 | markers.push(eventMarker); 409 | }); 410 | 411 | await client.injectMarker('face', 1532808289990); 412 | await client.injectMarker('house', 1532808281390); 413 | await client.injectMarker('face', 1532808282390); 414 | await client.injectMarker('house', 1532808285390); 415 | 416 | expect(markers.length).toBe(4); 417 | expect(markers[markers.length - 1]).toEqual({ value: 'house', timestamp: 1532808285390 }); 418 | }); 419 | 420 | it('should be able to timestamp on its own', async () => { 421 | const client = new MuseClient(); 422 | await client.connect(); 423 | 424 | const markers = []; 425 | client.eventMarkers.subscribe((eventMarker) => { 426 | markers.push(eventMarker); 427 | }); 428 | 429 | const startTime = new Date().getTime(); 430 | await client.injectMarker('house'); 431 | await client.injectMarker('face'); 432 | await client.injectMarker('house'); 433 | await client.injectMarker('face'); 434 | 435 | expect(markers.length).toBe(4); 436 | const lastMarker = markers[markers.length - 1]; 437 | expect(lastMarker.timestamp).toBeGreaterThanOrEqual(startTime); 438 | expect(lastMarker.timestamp).toBeLessThanOrEqual(new Date().getTime()); 439 | }); 440 | }); 441 | }); 442 | -------------------------------------------------------------------------------- /src/muse.ts: -------------------------------------------------------------------------------- 1 | import { BehaviorSubject, fromEvent, merge, Observable, Subject } from 'rxjs'; 2 | import { filter, first, map, share, take } from 'rxjs/operators'; 3 | 4 | import { 5 | AccelerometerData, 6 | EEGReading, 7 | EventMarker, 8 | GyroscopeData, 9 | MuseControlResponse, 10 | MuseDeviceInfo, 11 | PPGReading, 12 | TelemetryData, 13 | XYZ, 14 | } from './lib/muse-interfaces'; 15 | import { 16 | decodeEEGSamples, 17 | decodePPGSamples, 18 | parseAccelerometer, 19 | parseControl, 20 | parseGyroscope, 21 | parseTelemetry, 22 | } from './lib/muse-parse'; 23 | import { decodeResponse, encodeCommand, observableCharacteristic } from './lib/muse-utils'; 24 | 25 | export { zipSamples, EEGSample } from './lib/zip-samples'; 26 | export { zipSamplesPpg, PPGSample } from './lib/zip-samplesPpg'; 27 | export { 28 | EEGReading, 29 | PPGReading, 30 | TelemetryData, 31 | AccelerometerData, 32 | GyroscopeData, 33 | XYZ, 34 | MuseControlResponse, 35 | MuseDeviceInfo, 36 | }; 37 | 38 | export const MUSE_SERVICE = 0xfe8d; 39 | const CONTROL_CHARACTERISTIC = '273e0001-4c4d-454d-96be-f03bac821358'; 40 | const TELEMETRY_CHARACTERISTIC = '273e000b-4c4d-454d-96be-f03bac821358'; 41 | const GYROSCOPE_CHARACTERISTIC = '273e0009-4c4d-454d-96be-f03bac821358'; 42 | const ACCELEROMETER_CHARACTERISTIC = '273e000a-4c4d-454d-96be-f03bac821358'; 43 | const PPG_CHARACTERISTICS = [ 44 | '273e000f-4c4d-454d-96be-f03bac821358', // ambient 0x37-0x39 45 | '273e0010-4c4d-454d-96be-f03bac821358', // infrared 0x3a-0x3c 46 | '273e0011-4c4d-454d-96be-f03bac821358', // red 0x3d-0x3f 47 | ]; 48 | export const PPG_FREQUENCY = 64; 49 | export const PPG_SAMPLES_PER_READING = 6; 50 | const EEG_CHARACTERISTICS = [ 51 | '273e0003-4c4d-454d-96be-f03bac821358', 52 | '273e0004-4c4d-454d-96be-f03bac821358', 53 | '273e0005-4c4d-454d-96be-f03bac821358', 54 | '273e0006-4c4d-454d-96be-f03bac821358', 55 | '273e0007-4c4d-454d-96be-f03bac821358', 56 | ]; 57 | export const EEG_FREQUENCY = 256; 58 | export const EEG_SAMPLES_PER_READING = 12; 59 | 60 | // These names match the characteristics defined in PPG_CHARACTERISTICS above 61 | export const ppgChannelNames = ['ambient', 'infrared', 'red']; 62 | 63 | // These names match the characteristics defined in EEG_CHARACTERISTICS above 64 | export const channelNames = ['TP9', 'AF7', 'AF8', 'TP10', 'AUX']; 65 | 66 | export class MuseClient { 67 | enableAux = false; 68 | enablePpg = false; 69 | deviceName: string | null = ''; 70 | connectionStatus = new BehaviorSubject(false); 71 | rawControlData: Observable; 72 | controlResponses: Observable; 73 | telemetryData: Observable; 74 | gyroscopeData: Observable; 75 | accelerometerData: Observable; 76 | eegReadings: Observable; 77 | ppgReadings: Observable; 78 | eventMarkers: Subject; 79 | 80 | private gatt: BluetoothRemoteGATTServer | null = null; 81 | private controlChar: BluetoothRemoteGATTCharacteristic; 82 | private eegCharacteristics: BluetoothRemoteGATTCharacteristic[]; 83 | private ppgCharacteristics: BluetoothRemoteGATTCharacteristic[]; 84 | 85 | private lastIndex: number | null = null; 86 | private lastTimestamp: number | null = null; 87 | 88 | async connect(gatt?: BluetoothRemoteGATTServer) { 89 | if (gatt) { 90 | this.gatt = gatt; 91 | } else { 92 | const device = await navigator.bluetooth.requestDevice({ 93 | filters: [{ services: [MUSE_SERVICE] }], 94 | }); 95 | this.gatt = await device.gatt!.connect(); 96 | } 97 | this.deviceName = this.gatt.device.name || null; 98 | 99 | const service = await this.gatt.getPrimaryService(MUSE_SERVICE); 100 | fromEvent(this.gatt.device, 'gattserverdisconnected') 101 | .pipe(first()) 102 | .subscribe(() => { 103 | this.gatt = null; 104 | this.connectionStatus.next(false); 105 | }); 106 | 107 | // Control 108 | this.controlChar = await service.getCharacteristic(CONTROL_CHARACTERISTIC); 109 | this.rawControlData = (await observableCharacteristic(this.controlChar)).pipe( 110 | map((data) => decodeResponse(new Uint8Array(data.buffer))), 111 | share(), 112 | ); 113 | this.controlResponses = parseControl(this.rawControlData); 114 | 115 | // Battery 116 | const telemetryCharacteristic = await service.getCharacteristic(TELEMETRY_CHARACTERISTIC); 117 | this.telemetryData = (await observableCharacteristic(telemetryCharacteristic)).pipe(map(parseTelemetry)); 118 | 119 | // Gyroscope 120 | const gyroscopeCharacteristic = await service.getCharacteristic(GYROSCOPE_CHARACTERISTIC); 121 | this.gyroscopeData = (await observableCharacteristic(gyroscopeCharacteristic)).pipe(map(parseGyroscope)); 122 | 123 | // Accelerometer 124 | const accelerometerCharacteristic = await service.getCharacteristic(ACCELEROMETER_CHARACTERISTIC); 125 | this.accelerometerData = (await observableCharacteristic(accelerometerCharacteristic)).pipe( 126 | map(parseAccelerometer), 127 | ); 128 | 129 | this.eventMarkers = new Subject(); 130 | 131 | // PPG 132 | if (this.enablePpg) { 133 | this.ppgCharacteristics = []; 134 | const ppgObservables = []; 135 | const ppgChannelCount = PPG_CHARACTERISTICS.length; 136 | for (let ppgChannelIndex = 0; ppgChannelIndex < ppgChannelCount; ppgChannelIndex++) { 137 | const characteristicId = PPG_CHARACTERISTICS[ppgChannelIndex]; 138 | const ppgChar = await service.getCharacteristic(characteristicId); 139 | ppgObservables.push( 140 | (await observableCharacteristic(ppgChar)).pipe( 141 | map((data) => { 142 | const eventIndex = data.getUint16(0); 143 | return { 144 | index: eventIndex, 145 | ppgChannel: ppgChannelIndex, 146 | samples: decodePPGSamples(new Uint8Array(data.buffer).subarray(2)), 147 | timestamp: this.getTimestamp(eventIndex, PPG_SAMPLES_PER_READING, PPG_FREQUENCY), 148 | }; 149 | }), 150 | ), 151 | ); 152 | this.ppgCharacteristics.push(ppgChar); 153 | } 154 | this.ppgReadings = merge(...ppgObservables); 155 | } 156 | 157 | // EEG 158 | this.eegCharacteristics = []; 159 | const eegObservables = []; 160 | const channelCount = this.enableAux ? EEG_CHARACTERISTICS.length : 4; 161 | for (let channelIndex = 0; channelIndex < channelCount; channelIndex++) { 162 | const characteristicId = EEG_CHARACTERISTICS[channelIndex]; 163 | const eegChar = await service.getCharacteristic(characteristicId); 164 | eegObservables.push( 165 | (await observableCharacteristic(eegChar)).pipe( 166 | map((data) => { 167 | const eventIndex = data.getUint16(0); 168 | return { 169 | electrode: channelIndex, 170 | index: eventIndex, 171 | samples: decodeEEGSamples(new Uint8Array(data.buffer).subarray(2)), 172 | timestamp: this.getTimestamp(eventIndex, EEG_SAMPLES_PER_READING, EEG_FREQUENCY), 173 | }; 174 | }), 175 | ), 176 | ); 177 | this.eegCharacteristics.push(eegChar); 178 | } 179 | this.eegReadings = merge(...eegObservables); 180 | this.connectionStatus.next(true); 181 | } 182 | 183 | async sendCommand(cmd: string) { 184 | await this.controlChar.writeValue(encodeCommand(cmd)); 185 | } 186 | 187 | async start() { 188 | await this.pause(); 189 | let preset = 'p21'; 190 | if (this.enablePpg) { 191 | preset = 'p50'; 192 | } else if (this.enableAux) { 193 | preset = 'p20'; 194 | } 195 | 196 | await this.controlChar.writeValue(encodeCommand(preset)); 197 | await this.controlChar.writeValue(encodeCommand('s')); 198 | await this.resume(); 199 | } 200 | 201 | async pause() { 202 | await this.sendCommand('h'); 203 | } 204 | 205 | async resume() { 206 | await this.sendCommand('d'); 207 | } 208 | 209 | async deviceInfo() { 210 | const resultListener = this.controlResponses 211 | .pipe( 212 | filter((r) => !!r.fw), 213 | take(1), 214 | ) 215 | .toPromise(); 216 | await this.sendCommand('v1'); 217 | return resultListener as Promise; 218 | } 219 | 220 | async injectMarker(value: string | number, timestamp: number = new Date().getTime()) { 221 | await this.eventMarkers.next({ value, timestamp }); 222 | } 223 | 224 | disconnect() { 225 | if (this.gatt) { 226 | this.lastIndex = null; 227 | this.lastTimestamp = null; 228 | this.gatt.disconnect(); 229 | this.connectionStatus.next(false); 230 | } 231 | } 232 | 233 | private getTimestamp(eventIndex: number, samplesPerReading: number, frequency: number) { 234 | const READING_DELTA = 1000 * (1.0 / frequency) * samplesPerReading; 235 | if (this.lastIndex === null || this.lastTimestamp === null) { 236 | this.lastIndex = eventIndex; 237 | this.lastTimestamp = new Date().getTime() - READING_DELTA; 238 | } 239 | 240 | // Handle wrap around 241 | while (this.lastIndex - eventIndex > 0x1000) { 242 | eventIndex += 0x10000; 243 | } 244 | 245 | if (eventIndex === this.lastIndex) { 246 | return this.lastTimestamp; 247 | } 248 | if (eventIndex > this.lastIndex) { 249 | this.lastTimestamp += READING_DELTA * (eventIndex - this.lastIndex); 250 | this.lastIndex = eventIndex; 251 | return this.lastTimestamp; 252 | } else { 253 | return this.lastTimestamp - READING_DELTA * (this.lastIndex - eventIndex); 254 | } 255 | } 256 | } 257 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "module": "commonjs", 5 | "moduleResolution": "node", 6 | "noImplicitAny": true, 7 | "strictNullChecks": true, 8 | "removeComments": false, 9 | "sourceMap": true, 10 | "declaration": true, 11 | "noEmitOnError": true, 12 | "rootDir": "src", 13 | "outDir": "dist", 14 | "lib": ["es5", "dom", "es2015.core", "es2015.promise", "es2015.collection", "es2015.iterable"], 15 | "types": ["web-bluetooth", "jest"] 16 | }, 17 | "include": ["src/**/*.ts"], 18 | "exclude": ["src/**/*.spec.ts"] 19 | } 20 | -------------------------------------------------------------------------------- /tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "error", 3 | "extends": ["tslint:recommended"], 4 | "jsRules": {}, 5 | "rules": { 6 | "quotemark": [true, "single", "avoid-escape", "avoid-template"], 7 | "member-access": [true, "no-public"], 8 | "interface-name": [true, "never-prefix"] 9 | }, 10 | "rulesDirectory": [] 11 | } 12 | -------------------------------------------------------------------------------- /wallaby.js: -------------------------------------------------------------------------------- 1 | module.exports = function (wallaby) { 2 | return { 3 | files: [ 4 | 'src/**/*.ts', 5 | { pattern: 'src/**/*.spec.ts', ignore: true }, 6 | ], 7 | 8 | tests: ['src/**/*.spec.ts'], 9 | 10 | env: { 11 | type: 'node', 12 | runner: 'node' 13 | }, 14 | 15 | testFramework: 'jest' 16 | }; 17 | }; 18 | --------------------------------------------------------------------------------