├── .github └── workflows │ └── test.yml ├── .gitignore ├── .husky ├── commit-msg └── pre-commit ├── LICENSE ├── README.md ├── config ├── eslint │ ├── config.json │ ├── src.json │ └── test.json ├── karma │ ├── config-expectation-chrome-canary.js │ ├── config-expectation-chrome.js │ └── config-unit.js ├── lint-staged │ └── config.json ├── prettier │ └── config.json ├── rollup │ └── bundle.mjs └── tslint │ └── src.json ├── package-lock.json ├── package.json ├── src ├── factories │ ├── midi-file-slicer.ts │ ├── midi-player-factory.ts │ ├── start-interval-scheduler.ts │ └── start-timeout-scheduler.ts ├── helpers │ └── encode-midi-message.ts ├── interfaces │ ├── index.ts │ ├── interval.ts │ ├── midi-output.ts │ ├── midi-player-factory-options.ts │ ├── midi-player-options.ts │ └── midi-player.ts ├── midi-player.ts ├── module.ts ├── tsconfig.json └── types │ ├── index.ts │ ├── midi-file-slicer-factory.ts │ ├── midi-player-factory-factory.ts │ ├── midi-player-factory.ts │ └── state.ts └── test ├── expectation └── chrome │ ├── canary │ └── midi-output.js │ └── current │ └── midi-output.js ├── mock ├── midi-file-slicer.js ├── midi-output.js └── performance.js └── unit ├── factories ├── midi-player-factory.js ├── start-interval-scheduler.js └── start-timeout-scheduler.js ├── helpers └── encode-midi-message.js └── midi-player.js /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: Test 2 | 3 | on: 4 | push: 5 | branches: 6 | - master 7 | pull_request: 8 | branches: 9 | - master 10 | 11 | jobs: 12 | build: 13 | runs-on: ubuntu-latest 14 | 15 | strategy: 16 | matrix: 17 | node-version: [20.x] 18 | target: [chrome, safari] 19 | type: [unit] 20 | max-parallel: 3 21 | 22 | steps: 23 | - name: Checkout repository 24 | uses: actions/checkout@v4 25 | 26 | - name: Install Node.js ${{ matrix.node-version }} 27 | uses: actions/setup-node@v4 28 | with: 29 | node-version: ${{ matrix.node-version }} 30 | 31 | - name: Cache node modules 32 | uses: actions/cache@v4 33 | with: 34 | path: ~/.npm 35 | key: ${{ runner.os }}-node-${{ hashFiles('**/package-lock.json') }} 36 | restore-keys: | 37 | ${{ runner.os }}-node- 38 | 39 | - name: Install dependencies 40 | run: npm ci 41 | 42 | - env: 43 | SAUCE_ACCESS_KEY: ${{ secrets.SAUCE_ACCESS_KEY }} 44 | SAUCE_USERNAME: ${{ secrets.SAUCE_USERNAME }} 45 | TARGET: ${{ matrix.target }} 46 | TYPE: ${{ matrix.type }} 47 | name: Run ${{ matrix.type }} tests 48 | run: npm test 49 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.DS_Store 2 | node_modules/ 3 | /build/ 4 | -------------------------------------------------------------------------------- /.husky/commit-msg: -------------------------------------------------------------------------------- 1 | commitlint --edit --extends @commitlint/config-angular 2 | -------------------------------------------------------------------------------- /.husky/pre-commit: -------------------------------------------------------------------------------- 1 | lint-staged --config config/lint-staged/config.json && npm run lint 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Christoph Guttandin 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 all 13 | 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 THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # midi-player 2 | 3 | **A MIDI player which sends MIDI messages to connected devices.** 4 | 5 | [![version](https://img.shields.io/npm/v/midi-player.svg?style=flat-square)](https://www.npmjs.com/package/midi-player) 6 | 7 | This module provides a player which sends MIDI messages to connected devices. It schedules the messages with a look ahead of about 500 milliseconds. It does not directly rely on the [Web MIDI API](https://webaudio.github.io/web-midi-api/) but expects a [MIDIOutput](https://webaudio.github.io/web-midi-api/#midioutput-interface) to be passed as constructor argument. But theoretically that could be anything which implements the same interface. 8 | 9 | ## Usage 10 | 11 | `midi-player` is published on [npm](https://www.npmjs.com/package/midi-player) and can be installed as usual. 12 | 13 | ```shell 14 | npm install midi-player 15 | ``` 16 | 17 | The only exported function is a factory method to create new player instances. 18 | 19 | ```js 20 | import { create } from 'midi-player'; 21 | 22 | // This is a JSON object which represents a MIDI file. 23 | const json = { 24 | division: 480, 25 | format: 1, 26 | tracks: [ 27 | { channel: 0, delta: 0, noteOn: { noteNumber: 36, velocity: 100 } }, 28 | { channel: 0, delta: 240, noteOff: { noteNumber: 36, velocity: 64 } }, 29 | { delta: 0, endOfTrack: true } 30 | ] 31 | }; 32 | 33 | // This is a quick & dirty approach to grab the first known MIDI output. 34 | const midiAccess = await navigator.requestMIDIAccess(); 35 | const midiOutput = Array.from(midiAccess.outputs)[0]; 36 | 37 | const midiPlayer = create({ json, midiOutput }); 38 | ``` 39 | 40 | By default all status events will be sent. But it's possible to provide a custom filter function. The following player will only send note off and note on events. 41 | 42 | ```js 43 | const midiPlayer = create({ 44 | filterMidiMessage: (event) => 'noteOff' in event || 'noteOn' in event 45 | // ... other options as described above 46 | }); 47 | ``` 48 | 49 | If you want to play a binary MIDI file you can use the [midi-json-parser](https://github.com/chrisguttandin/midi-json-parser) package to transform it into a compatible JSON representation. 50 | 51 | ### position 52 | 53 | The `position` is set to the current `position` in milliseconds. 54 | 55 | ```js 56 | midiPlayer.position; 57 | ``` 58 | 59 | ### state 60 | 61 | The `state` property will either be set to `'paused'`, `'playing'`, or `'stopped'`. 62 | 63 | ```js 64 | midiPlayer.state; 65 | ``` 66 | 67 | ### play() 68 | 69 | Calling `play()` will initiate the playback from the start. 70 | 71 | ```js 72 | midiPlayer.play().then(() => { 73 | // All MIDI messages have been sent when the promise returned by play() resolves. 74 | }); 75 | ``` 76 | 77 | It can only be called when the `state` of the player is `'stopped'`. 78 | 79 | ### pause() 80 | 81 | Calling `pause()` will pause the playback at the current `position`. 82 | 83 | ```js 84 | midiPlayer.pause(); 85 | ``` 86 | 87 | It can only be called when the `state` of the player is `'playing'`. 88 | 89 | ### resume() 90 | 91 | Calling `resume()` will resume a previously paused playback at the current `position`. 92 | 93 | ```js 94 | midiPlayer.resume().then(() => { 95 | // All MIDI messages have been sent when the promise returned by resume() resolves. 96 | }); 97 | ``` 98 | 99 | It can only be called when the `state` of the player is `'paused'`. 100 | 101 | ### stop() 102 | 103 | Calling `stop()` will stop the player. 104 | 105 | ```js 106 | midiPlayer.stop(); 107 | ``` 108 | 109 | It can only be called when the `state` of the player is not `'stopped'`. 110 | 111 | ## Acknowledgement 112 | 113 | Most of the features of this package have been originally developed by [@infojunkie](https://github.com/infojunkie) who maintains a midi-player fork ([infojunkie/midi-player](https://github.com/infojunkie/midi-player)) with even more functionality. 114 | -------------------------------------------------------------------------------- /config/eslint/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "node": true 4 | }, 5 | "extends": "eslint-config-holy-grail", 6 | "rules": { 7 | "no-sync": "off", 8 | "node/no-missing-require": "off" 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /config/eslint/src.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true 4 | }, 5 | "extends": "eslint-config-holy-grail" 6 | } 7 | -------------------------------------------------------------------------------- /config/eslint/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "mocha": true 5 | }, 6 | "extends": "eslint-config-holy-grail", 7 | "globals": { 8 | "expect": "readonly" 9 | }, 10 | "rules": { 11 | "no-unused-expressions": "off", 12 | "node/file-extension-in-import": "off", 13 | "node/no-missing-require": "off" 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /config/karma/config-expectation-chrome-canary.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const { DefinePlugin } = require('webpack'); 3 | 4 | module.exports = (config) => { 5 | config.set({ 6 | basePath: '../../', 7 | 8 | browserDisconnectTimeout: 100000, 9 | 10 | browserNoActivityTimeout: 100000, 11 | 12 | browsers: ['ChromeCanaryHeadless'], 13 | 14 | client: { 15 | mocha: { 16 | bail: true, 17 | timeout: 20000 18 | } 19 | }, 20 | 21 | concurrency: 1, 22 | 23 | files: ['test/expectation/chrome/canary/**/*.js'], 24 | 25 | frameworks: ['mocha', 'sinon-chai'], 26 | 27 | preprocessors: { 28 | 'test/expectation/chrome/canary/**/*.js': 'webpack' 29 | }, 30 | 31 | reporters: ['dots'], 32 | 33 | webpack: { 34 | mode: 'development', 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.ts?$/, 39 | use: { 40 | loader: 'ts-loader', 41 | options: { 42 | compilerOptions: { 43 | declaration: false, 44 | declarationMap: false 45 | } 46 | } 47 | } 48 | } 49 | ] 50 | }, 51 | plugins: [ 52 | new DefinePlugin({ 53 | 'process.env': { 54 | CI: JSON.stringify(env.CI) 55 | } 56 | }) 57 | ], 58 | resolve: { 59 | extensions: ['.js', '.ts'], 60 | fallback: { util: false } 61 | } 62 | }, 63 | 64 | webpackMiddleware: { 65 | noInfo: true 66 | } 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /config/karma/config-expectation-chrome.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const { DefinePlugin } = require('webpack'); 3 | 4 | module.exports = (config) => { 5 | config.set({ 6 | basePath: '../../', 7 | 8 | browserDisconnectTimeout: 100000, 9 | 10 | browserNoActivityTimeout: 100000, 11 | 12 | browsers: ['ChromeHeadless'], 13 | 14 | client: { 15 | mocha: { 16 | bail: true, 17 | timeout: 20000 18 | } 19 | }, 20 | 21 | concurrency: 1, 22 | 23 | files: ['test/expectation/chrome/current/**/*.js'], 24 | 25 | frameworks: ['mocha', 'sinon-chai'], 26 | 27 | preprocessors: { 28 | 'test/expectation/chrome/current/**/*.js': 'webpack' 29 | }, 30 | 31 | reporters: ['dots'], 32 | 33 | webpack: { 34 | mode: 'development', 35 | module: { 36 | rules: [ 37 | { 38 | test: /\.ts?$/, 39 | use: { 40 | loader: 'ts-loader', 41 | options: { 42 | compilerOptions: { 43 | declaration: false, 44 | declarationMap: false 45 | } 46 | } 47 | } 48 | } 49 | ] 50 | }, 51 | plugins: [ 52 | new DefinePlugin({ 53 | 'process.env': { 54 | CI: JSON.stringify(env.CI) 55 | } 56 | }) 57 | ], 58 | resolve: { 59 | extensions: ['.js', '.ts'], 60 | fallback: { util: false } 61 | } 62 | }, 63 | 64 | webpackMiddleware: { 65 | noInfo: true 66 | } 67 | }); 68 | }; 69 | -------------------------------------------------------------------------------- /config/karma/config-unit.js: -------------------------------------------------------------------------------- 1 | const { env } = require('process'); 2 | const { DefinePlugin } = require('webpack'); 3 | 4 | module.exports = (config) => { 5 | config.set({ 6 | basePath: '../../', 7 | 8 | browserDisconnectTimeout: 100000, 9 | 10 | browserNoActivityTimeout: 100000, 11 | 12 | client: { 13 | mocha: { 14 | bail: true, 15 | timeout: 20000 16 | } 17 | }, 18 | 19 | concurrency: 1, 20 | 21 | files: [ 22 | { 23 | included: false, 24 | pattern: 'src/**', 25 | served: false, 26 | watched: true 27 | }, 28 | 'test/unit/**/*.js' 29 | ], 30 | 31 | frameworks: ['mocha', 'sinon-chai'], 32 | 33 | preprocessors: { 34 | 'test/unit/**/*.js': 'webpack' 35 | }, 36 | 37 | reporters: ['dots'], 38 | 39 | webpack: { 40 | mode: 'development', 41 | module: { 42 | rules: [ 43 | { 44 | test: /\.ts?$/, 45 | use: { 46 | loader: 'ts-loader', 47 | options: { 48 | compilerOptions: { 49 | declaration: false, 50 | declarationMap: false 51 | } 52 | } 53 | } 54 | } 55 | ] 56 | }, 57 | plugins: [ 58 | new DefinePlugin({ 59 | 'process.env': { 60 | CI: JSON.stringify(env.CI) 61 | } 62 | }) 63 | ], 64 | resolve: { 65 | extensions: ['.js', '.ts'], 66 | fallback: { util: false } 67 | } 68 | }, 69 | 70 | webpackMiddleware: { 71 | noInfo: true 72 | } 73 | }); 74 | 75 | if (env.CI) { 76 | config.set({ 77 | browsers: 78 | env.TARGET === 'chrome' 79 | ? ['ChromeSauceLabs'] 80 | : env.TARGET === 'firefox' 81 | ? ['FirefoxSauceLabs'] 82 | : env.TARGET === 'safari' 83 | ? ['SafariSauceLabs'] 84 | : ['ChromeSauceLabs', 'FirefoxSauceLabs', 'SafariSauceLabs'], 85 | 86 | captureTimeout: 300000, 87 | 88 | customLaunchers: { 89 | ChromeSauceLabs: { 90 | base: 'SauceLabs', 91 | browserName: 'chrome', 92 | captureTimeout: 300, 93 | platform: 'macOS 12' 94 | }, 95 | FirefoxSauceLabs: { 96 | base: 'SauceLabs', 97 | browserName: 'firefox', 98 | captureTimeout: 300, 99 | geckodriverVersion: '0.30.0', 100 | platform: 'macOS 12' 101 | }, 102 | SafariSauceLabs: { 103 | base: 'SauceLabs', 104 | browserName: 'safari', 105 | captureTimeout: 300, 106 | platform: 'macOS 12' 107 | } 108 | }, 109 | 110 | sauceLabs: { 111 | recordVideo: false 112 | } 113 | }); 114 | } else { 115 | config.set({ 116 | browsers: ['ChromeCanaryHeadless', 'ChromeHeadless', 'FirefoxDeveloperHeadless', 'FirefoxHeadless', 'Safari'] 117 | }); 118 | } 119 | }; 120 | -------------------------------------------------------------------------------- /config/lint-staged/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": "prettier --config config/prettier/config.json --ignore-unknown --write" 3 | } 4 | -------------------------------------------------------------------------------- /config/prettier/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "arrowParens": "always", 3 | "printWidth": 140, 4 | "quoteProps": "consistent", 5 | "singleQuote": true, 6 | "tabWidth": 4, 7 | "trailingComma": "none" 8 | } 9 | -------------------------------------------------------------------------------- /config/rollup/bundle.mjs: -------------------------------------------------------------------------------- 1 | import babel from '@rollup/plugin-babel'; 2 | 3 | // eslint-disable-next-line import/no-default-export 4 | export default { 5 | input: 'build/es2019/module.js', 6 | output: { 7 | file: 'build/es5/bundle.js', 8 | format: 'umd', 9 | name: 'midiPlayer' 10 | }, 11 | plugins: [ 12 | babel({ 13 | babelHelpers: 'runtime', 14 | exclude: 'node_modules/**', 15 | plugins: ['@babel/plugin-external-helpers', '@babel/plugin-transform-runtime'], 16 | presets: [ 17 | [ 18 | '@babel/preset-env', 19 | { 20 | modules: false 21 | } 22 | ] 23 | ] 24 | }) 25 | ] 26 | }; 27 | -------------------------------------------------------------------------------- /config/tslint/src.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "tslint-config-holy-grail" 3 | } 4 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "author": "Christoph Guttandin", 3 | "bugs": { 4 | "url": "https://github.com/chrisguttandin/midi-player/issues" 5 | }, 6 | "config": { 7 | "commitizen": { 8 | "path": "cz-conventional-changelog" 9 | } 10 | }, 11 | "dependencies": { 12 | "@babel/runtime": "^7.27.1", 13 | "json-midi-message-encoder": "^4.1.58", 14 | "midi-file-slicer": "^6.0.117", 15 | "midi-json-parser-worker": "^8.1.58", 16 | "tslib": "^2.8.1", 17 | "worker-timers": "^8.0.21" 18 | }, 19 | "description": "A MIDI player which sends MIDI messages to connected devices.", 20 | "devDependencies": { 21 | "@babel/core": "^7.27.1", 22 | "@babel/plugin-external-helpers": "^7.27.1", 23 | "@babel/plugin-transform-runtime": "^7.27.1", 24 | "@babel/preset-env": "^7.27.2", 25 | "@commitlint/cli": "^19.8.1", 26 | "@commitlint/config-angular": "^19.8.1", 27 | "@rollup/plugin-babel": "^6.0.4", 28 | "chai": "^4.3.10", 29 | "commitizen": "^4.3.1", 30 | "cz-conventional-changelog": "^3.3.0", 31 | "eslint": "^8.57.0", 32 | "eslint-config-holy-grail": "^60.0.34", 33 | "husky": "^9.1.7", 34 | "karma": "^6.4.4", 35 | "karma-chrome-launcher": "^3.2.0", 36 | "karma-firefox-launcher": "^2.1.3", 37 | "karma-mocha": "^2.0.1", 38 | "karma-sauce-launcher": "^4.3.6", 39 | "karma-sinon-chai": "^2.0.2", 40 | "karma-webkit-launcher": "^2.6.0", 41 | "karma-webpack": "^5.0.1", 42 | "lint-staged": "^15.5.0", 43 | "mocha": "^11.2.2", 44 | "prettier": "^3.5.3", 45 | "rimraf": "^6.0.1", 46 | "rollup": "^4.40.2", 47 | "sinon": "^17.0.2", 48 | "sinon-chai": "^3.7.0", 49 | "ts-loader": "^9.5.2", 50 | "tsconfig-holy-grail": "^15.0.2", 51 | "tslint": "^6.1.3", 52 | "tslint-config-holy-grail": "^56.0.6", 53 | "typescript": "^5.8.3", 54 | "webpack": "^5.99.8" 55 | }, 56 | "files": [ 57 | "build/es2019/", 58 | "build/es5/", 59 | "src/" 60 | ], 61 | "homepage": "https://github.com/chrisguttandin/midi-player", 62 | "keywords": [ 63 | "MIDI", 64 | "Web MIDI API" 65 | ], 66 | "license": "MIT", 67 | "main": "build/es5/bundle.js", 68 | "module": "build/es2019/module.js", 69 | "name": "midi-player", 70 | "repository": { 71 | "type": "git", 72 | "url": "https://github.com/chrisguttandin/midi-player.git" 73 | }, 74 | "scripts": { 75 | "build": "rimraf build/* && tsc --project src/tsconfig.json && rollup --config config/rollup/bundle.mjs", 76 | "lint": "npm run lint:config && npm run lint:src && npm run lint:test", 77 | "lint:config": "eslint --config config/eslint/config.json --ext .cjs --ext .js --ext .mjs --report-unused-disable-directives config/", 78 | "lint:src": "tslint --config config/tslint/src.json --project src/tsconfig.json src/*.ts src/**/*.ts", 79 | "lint:test": "eslint --config config/eslint/test.json --ext .js --report-unused-disable-directives test/", 80 | "prepare": "husky", 81 | "prepublishOnly": "npm run build", 82 | "test": "npm run lint && npm run build && npm run test:expectation-chrome && npm run test:expectation-chrome-canary && npm run test:unit", 83 | "test:expectation-chrome": "if [ \"$TYPE\" = \"\" -o \"$TYPE\" = \"expectation\" ] && [ \"$TARGET\" = \"\" -o \"$TARGET\" = \"chrome\" ]; then karma start config/karma/config-expectation-chrome.js --single-run; fi", 84 | "test:expectation-chrome-canary": "if [ \"$TYPE\" = \"\" -o \"$TYPE\" = \"expectation\" ] && [ \"$TARGET\" = \"\" -o \"$TARGET\" = \"chrome-canary\" ]; then karma start config/karma/config-expectation-chrome-canary.js --single-run; fi", 85 | "test:unit": "if [ \"$TYPE\" = \"\" -o \"$TYPE\" = \"unit\" ]; then karma start config/karma/config-unit.js --single-run; fi" 86 | }, 87 | "types": "build/es2019/module.d.ts", 88 | "version": "8.0.16" 89 | } 90 | -------------------------------------------------------------------------------- /src/factories/midi-file-slicer.ts: -------------------------------------------------------------------------------- 1 | import { MidiFileSlicer } from 'midi-file-slicer'; 2 | import { TMidiFileSlicerFactory } from '../types'; 3 | 4 | export const createMidiFileSlicer: TMidiFileSlicerFactory = (json) => new MidiFileSlicer({ json }); 5 | -------------------------------------------------------------------------------- /src/factories/midi-player-factory.ts: -------------------------------------------------------------------------------- 1 | import { encodeMidiMessage } from '../helpers/encode-midi-message'; 2 | import { MidiPlayer } from '../midi-player'; 3 | import { TMidiPlayerFactoryFactory } from '../types'; 4 | 5 | export const createMidiPlayerFactory: TMidiPlayerFactoryFactory = (createMidiFileSlicer, startIntervalScheduler, startTimeoutScheduler) => { 6 | return (options) => { 7 | const midiFileSlicer = createMidiFileSlicer(options.json); 8 | 9 | return new MidiPlayer({ 10 | filterMidiMessage: (event) => 'channel' in event, 11 | ...options, 12 | encodeMidiMessage, 13 | midiFileSlicer, 14 | startIntervalScheduler, 15 | startTimeoutScheduler 16 | }); 17 | }; 18 | }; 19 | -------------------------------------------------------------------------------- /src/factories/start-interval-scheduler.ts: -------------------------------------------------------------------------------- 1 | import { IInterval } from '../interfaces'; 2 | 3 | const INTERVAL = 500; 4 | 5 | export const createStartIntervalScheduler = 6 | (clearInterval: Window['clearInterval'], performance: Window['performance'], setInterval: Window['setInterval']) => 7 | (next: (interval: IInterval) => void) => { 8 | const start = performance.now(); 9 | 10 | let nextTick = start + INTERVAL; 11 | let end = nextTick + INTERVAL; 12 | 13 | const intervalId = setInterval(() => { 14 | if (performance.now() >= nextTick) { 15 | nextTick = end; 16 | end += INTERVAL; 17 | 18 | next({ end, start: nextTick }); 19 | } 20 | }, INTERVAL / 10); 21 | 22 | next({ end, start }); 23 | 24 | return [() => performance.now(), () => clearInterval(intervalId)]; 25 | }; 26 | -------------------------------------------------------------------------------- /src/factories/start-timeout-scheduler.ts: -------------------------------------------------------------------------------- 1 | export const createStartTimeoutScheduler = 2 | (clearTimeout: Window['clearTimeout'], setTimeout: Window['setTimeout']) => (handler: () => void, timeout: number) => { 3 | const timeoutId = setTimeout(handler, timeout); 4 | 5 | return () => clearTimeout(timeoutId); 6 | }; 7 | -------------------------------------------------------------------------------- /src/helpers/encode-midi-message.ts: -------------------------------------------------------------------------------- 1 | import { encode } from 'json-midi-message-encoder'; 2 | import { TMidiEvent } from 'midi-json-parser-worker'; 3 | 4 | export const encodeMidiMessage = (event: TMidiEvent): Uint8Array => { 5 | return new Uint8Array(encode(event)); 6 | }; 7 | -------------------------------------------------------------------------------- /src/interfaces/index.ts: -------------------------------------------------------------------------------- 1 | export * from './interval'; 2 | export * from './midi-player'; 3 | export * from './midi-player-factory-options'; 4 | export * from './midi-player-options'; 5 | export * from './midi-output'; 6 | -------------------------------------------------------------------------------- /src/interfaces/interval.ts: -------------------------------------------------------------------------------- 1 | export interface IInterval { 2 | end: number; 3 | 4 | start: number; 5 | } 6 | -------------------------------------------------------------------------------- /src/interfaces/midi-output.ts: -------------------------------------------------------------------------------- 1 | // This is an incomplete version of the MIDIOutput specification. 2 | 3 | export interface IMidiOutput { 4 | clear?(): void; 5 | 6 | send(data: number[] | Uint8Array, timestamp?: number): void; 7 | } 8 | -------------------------------------------------------------------------------- /src/interfaces/midi-player-factory-options.ts: -------------------------------------------------------------------------------- 1 | import { IMidiFile, TMidiEvent } from 'midi-json-parser-worker'; 2 | import { IMidiOutput } from './midi-output'; 3 | 4 | export interface IMidiPlayerFactoryOptions { 5 | json: IMidiFile; 6 | 7 | midiOutput: IMidiOutput; 8 | 9 | filterMidiMessage?(event: TMidiEvent): boolean; 10 | } 11 | -------------------------------------------------------------------------------- /src/interfaces/midi-player-options.ts: -------------------------------------------------------------------------------- 1 | import { MidiFileSlicer } from 'midi-file-slicer'; 2 | import { TMidiEvent } from 'midi-json-parser-worker'; 3 | import { createStartIntervalScheduler } from '../factories/start-interval-scheduler'; 4 | import { createStartTimeoutScheduler } from '../factories/start-timeout-scheduler'; 5 | import { IMidiPlayerFactoryOptions } from './midi-player-factory-options'; 6 | 7 | export interface IMidiPlayerOptions extends Required { 8 | midiFileSlicer: MidiFileSlicer; 9 | 10 | startIntervalScheduler: ReturnType; 11 | 12 | startTimeoutScheduler: ReturnType; 13 | 14 | encodeMidiMessage(event: TMidiEvent): Uint8Array; 15 | } 16 | -------------------------------------------------------------------------------- /src/interfaces/midi-player.ts: -------------------------------------------------------------------------------- 1 | export interface IMidiPlayer { 2 | position: null | number; 3 | 4 | state: 'paused' | 'playing' | 'stopped'; 5 | 6 | pause(): void; 7 | 8 | play(): Promise; 9 | 10 | resume(): Promise; 11 | 12 | stop(): void; 13 | } 14 | -------------------------------------------------------------------------------- /src/midi-player.ts: -------------------------------------------------------------------------------- 1 | import { MidiFileSlicer } from 'midi-file-slicer'; 2 | import { IMidiFile, TMidiEvent } from 'midi-json-parser-worker'; 3 | import { createStartIntervalScheduler } from './factories/start-interval-scheduler'; 4 | import { createStartTimeoutScheduler } from './factories/start-timeout-scheduler'; 5 | import { IMidiOutput, IMidiPlayer, IMidiPlayerOptions } from './interfaces'; 6 | import { TState } from './types'; 7 | 8 | const ALL_SOUND_OFF_EVENT_DATA = Array.from({ length: 16 }, (_, index) => new Uint8Array([176 + index, 120, 0])); 9 | 10 | export class MidiPlayer implements IMidiPlayer { 11 | private _encodeMidiMessage: (event: TMidiEvent) => Uint8Array; 12 | 13 | private _filterMidiMessage: (event: TMidiEvent) => boolean; 14 | 15 | private _json: IMidiFile; 16 | 17 | private _midiFileSlicer: MidiFileSlicer; 18 | 19 | private _midiOutput: IMidiOutput; 20 | 21 | private _startIntervalScheduler: ReturnType; 22 | 23 | private _startTimeoutScheduler: ReturnType; 24 | 25 | private _state: null | TState; 26 | 27 | constructor({ 28 | encodeMidiMessage, 29 | filterMidiMessage, 30 | json, 31 | midiFileSlicer, 32 | midiOutput, 33 | startIntervalScheduler, 34 | startTimeoutScheduler 35 | }: IMidiPlayerOptions) { 36 | this._encodeMidiMessage = encodeMidiMessage; 37 | this._filterMidiMessage = filterMidiMessage; 38 | this._json = json; 39 | this._midiFileSlicer = midiFileSlicer; 40 | this._midiOutput = midiOutput; 41 | this._startIntervalScheduler = startIntervalScheduler; 42 | this._startTimeoutScheduler = startTimeoutScheduler; 43 | this._state = null; 44 | } 45 | 46 | public get position(): null | number { 47 | return this._state === null 48 | ? 0 49 | : this._state.peekScheduler === null 50 | ? this._state.offset 51 | : this._state.peekScheduler() - this._state.offset; 52 | } 53 | 54 | public get state(): 'paused' | 'playing' | 'stopped' { 55 | return this._state === null ? 'stopped' : this._state.peekScheduler === null ? 'paused' : 'playing'; 56 | } 57 | 58 | public pause(): void { 59 | if (this._state === null || this._state.peekScheduler === null) { 60 | throw new Error('The player is not playing.'); 61 | } 62 | 63 | this._clear(); 64 | 65 | const { endOfTrackEventTimes, resolve, peekScheduler, stopScheduler } = this._state; 66 | const positionWithOffset = peekScheduler(); 67 | 68 | this._state = { 69 | ...this._state, 70 | endOfTrackEventTimes: endOfTrackEventTimes.filter((time) => time < positionWithOffset), 71 | offset: positionWithOffset - this._state.offset, 72 | peekScheduler: null, 73 | stopScheduler: null 74 | }; 75 | 76 | stopScheduler(); 77 | resolve(); 78 | } 79 | 80 | public play(): Promise { 81 | if (this._state !== null) { 82 | throw new Error('The player is not stopped.'); 83 | } 84 | 85 | return this._schedule([], 0); 86 | } 87 | 88 | public resume(): Promise { 89 | if (this._state === null || this._state.peekScheduler !== null) { 90 | throw new Error('The player is not paused.'); 91 | } 92 | 93 | const { endOfTrackEventTimes, offset } = this._state; 94 | 95 | this._state = null; 96 | 97 | return this._schedule(endOfTrackEventTimes, offset); 98 | } 99 | 100 | public stop(): void { 101 | if (this._state === null) { 102 | throw new Error('The player is already stopped.'); 103 | } 104 | 105 | if (this._state.stopScheduler === null) { 106 | this._state = null; 107 | } else { 108 | this._clear(); 109 | this._stop(this._state); 110 | } 111 | } 112 | 113 | private _clear(): void { 114 | // Bug #1: Chrome does not yet implement the clear() method. 115 | this._midiOutput.clear?.(); 116 | ALL_SOUND_OFF_EVENT_DATA.forEach((data) => this._midiOutput.send(data)); 117 | } 118 | 119 | private _schedule(endOfTrackEventTimes: number[], offset: number): Promise { 120 | return new Promise((resolve) => { 121 | const [peekScheduler, stopScheduler] = this._startIntervalScheduler(({ end, start }) => { 122 | if (this._state === null) { 123 | this._state = { endOfTrackEventTimes, offset: start - offset, resolve, peekScheduler: null, stopScheduler: null }; 124 | } 125 | 126 | const events = this._midiFileSlicer.slice(start - this._state.offset, end - this._state.offset); 127 | 128 | events 129 | .filter(({ event }) => this._filterMidiMessage(event)) 130 | .forEach(({ event, time }) => this._midiOutput.send(this._encodeMidiMessage(event), start + time)); 131 | 132 | const newEndOfTrackEventTimes = events 133 | .filter(({ event }) => MidiPlayer._isEndOfTrack(event)) 134 | .map(({ time }) => start + time); 135 | 136 | this._state.endOfTrackEventTimes.push(...newEndOfTrackEventTimes); 137 | 138 | if (this._state.endOfTrackEventTimes.length === this._json.tracks.length) { 139 | const timeout = Math.max(...newEndOfTrackEventTimes) - (this._state.peekScheduler?.() ?? start); 140 | 141 | if (timeout > 0) { 142 | this._state.stopScheduler?.(); 143 | 144 | this._state = { 145 | ...this._state, 146 | stopScheduler: this._startTimeoutScheduler(() => { 147 | this._state = null; 148 | 149 | resolve(); 150 | }, timeout) 151 | }; 152 | } else { 153 | this._stop(this._state); 154 | } 155 | } 156 | }); 157 | 158 | if (this._state?.stopScheduler === null) { 159 | this._state = { ...this._state, peekScheduler, stopScheduler }; 160 | } else { 161 | stopScheduler(); 162 | 163 | if (this._state !== null) { 164 | this._state = { ...this._state, peekScheduler }; 165 | } 166 | } 167 | }); 168 | } 169 | 170 | private _stop({ resolve, stopScheduler }: TState): void { 171 | this._state = null; 172 | 173 | stopScheduler?.(); 174 | resolve(); 175 | } 176 | 177 | private static _isEndOfTrack(event: TMidiEvent): boolean { 178 | return 'endOfTrack' in event; 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/module.ts: -------------------------------------------------------------------------------- 1 | import { clearInterval, clearTimeout, setInterval, setTimeout } from 'worker-timers'; 2 | import { createMidiFileSlicer } from './factories/midi-file-slicer'; 3 | import { createMidiPlayerFactory } from './factories/midi-player-factory'; 4 | import { createStartIntervalScheduler } from './factories/start-interval-scheduler'; 5 | import { createStartTimeoutScheduler } from './factories/start-timeout-scheduler'; 6 | import { TMidiPlayerFactory } from './types'; 7 | 8 | /* 9 | * @todo Explicitly referencing the barrel file seems to be necessary when enabling the 10 | * isolatedModules compiler option. 11 | */ 12 | export * from './interfaces/index'; 13 | export * from './types/index'; 14 | 15 | const createMidiPlayer = createMidiPlayerFactory( 16 | createMidiFileSlicer, 17 | createStartIntervalScheduler(clearInterval, performance, setInterval), 18 | createStartTimeoutScheduler(clearTimeout, setTimeout) 19 | ); 20 | 21 | export const create: TMidiPlayerFactory = (options) => createMidiPlayer(options); 22 | -------------------------------------------------------------------------------- /src/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "isolatedModules": true 4 | }, 5 | "extends": "tsconfig-holy-grail/src/tsconfig-browser" 6 | } 7 | -------------------------------------------------------------------------------- /src/types/index.ts: -------------------------------------------------------------------------------- 1 | export * from './midi-file-slicer-factory'; 2 | export * from './midi-player-factory'; 3 | export * from './midi-player-factory-factory'; 4 | export * from './state'; 5 | -------------------------------------------------------------------------------- /src/types/midi-file-slicer-factory.ts: -------------------------------------------------------------------------------- 1 | import { MidiFileSlicer } from 'midi-file-slicer'; 2 | import { IMidiFile } from 'midi-json-parser-worker'; 3 | 4 | export type TMidiFileSlicerFactory = (json: IMidiFile) => MidiFileSlicer; 5 | -------------------------------------------------------------------------------- /src/types/midi-player-factory-factory.ts: -------------------------------------------------------------------------------- 1 | import { TMidiFileSlicerFactory, TMidiPlayerFactory } from '.'; 2 | import { createStartIntervalScheduler } from '../factories/start-interval-scheduler'; 3 | import { createStartTimeoutScheduler } from '../factories/start-timeout-scheduler'; 4 | 5 | export type TMidiPlayerFactoryFactory = ( 6 | createMidiFileSlicer: TMidiFileSlicerFactory, 7 | startIntervalScheduler: ReturnType, 8 | startTimeoutScheduler: ReturnType 9 | ) => TMidiPlayerFactory; 10 | -------------------------------------------------------------------------------- /src/types/midi-player-factory.ts: -------------------------------------------------------------------------------- 1 | import { IMidiPlayer, IMidiPlayerFactoryOptions } from '../interfaces'; 2 | 3 | export type TMidiPlayerFactory = (options: IMidiPlayerFactoryOptions) => IMidiPlayer; 4 | -------------------------------------------------------------------------------- /src/types/state.ts: -------------------------------------------------------------------------------- 1 | import type { createStartIntervalScheduler } from '../factories/start-interval-scheduler'; 2 | 3 | export type TState = 4 | | { 5 | endOfTrackEventTimes: number[]; 6 | 7 | offset: number; 8 | 9 | peekScheduler: null; 10 | 11 | stopScheduler: null; 12 | 13 | resolve(): void; 14 | } 15 | | { 16 | endOfTrackEventTimes: number[]; 17 | 18 | offset: number; 19 | 20 | peekScheduler: null; 21 | 22 | stopScheduler: ReturnType>[1]; 23 | 24 | resolve(): void; 25 | } 26 | | { 27 | endOfTrackEventTimes: number[]; 28 | 29 | offset: number; 30 | 31 | peekScheduler: ReturnType>[0]; 32 | 33 | stopScheduler: ReturnType>[1]; 34 | 35 | resolve(): void; 36 | }; 37 | -------------------------------------------------------------------------------- /test/expectation/chrome/canary/midi-output.js: -------------------------------------------------------------------------------- 1 | describe('MIDIOutput', () => { 2 | describe('clear()', () => { 3 | // #1 https://issues.chromium.org/issues/40411677 4 | 5 | it('should not be implemented', () => { 6 | expect(MIDIOutput.prototype.clear).to.be.undefined; 7 | }); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/expectation/chrome/current/midi-output.js: -------------------------------------------------------------------------------- 1 | describe('MIDIOutput', () => { 2 | describe('clear()', () => { 3 | // #1 https://issues.chromium.org/issues/40411677 4 | 5 | it('should not be implemented', () => { 6 | expect(MIDIOutput.prototype.clear).to.be.undefined; 7 | }); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /test/mock/midi-file-slicer.js: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | export const midiFileSlicerMock = { 4 | slice: stub() 5 | }; 6 | -------------------------------------------------------------------------------- /test/mock/midi-output.js: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | export const midiOutputMock = { 4 | clear: stub(), 5 | send: stub() 6 | }; 7 | -------------------------------------------------------------------------------- /test/mock/performance.js: -------------------------------------------------------------------------------- 1 | import { stub } from 'sinon'; 2 | 3 | export const performanceMock = { 4 | now: stub() 5 | }; 6 | -------------------------------------------------------------------------------- /test/unit/factories/midi-player-factory.js: -------------------------------------------------------------------------------- 1 | import { createMidiPlayerFactory } from '../../../src/factories/midi-player-factory'; 2 | import { spy } from 'sinon'; 3 | 4 | describe('createMidiPlayerFactory()', () => { 5 | let midiFileSlicerFactory; 6 | let midiPlayerFactory; 7 | 8 | beforeEach(() => { 9 | const scheduler = 'a fake scheduler'; 10 | 11 | midiFileSlicerFactory = spy(); 12 | 13 | midiPlayerFactory = createMidiPlayerFactory(midiFileSlicerFactory, scheduler); 14 | }); 15 | 16 | it('should return a factory function', () => { 17 | expect(midiPlayerFactory).to.be.a('function'); 18 | }); 19 | 20 | describe('midiPlayerFactory()', () => { 21 | it('should create a new midiFileSlicer', () => { 22 | const json = 'a fake midi representation'; 23 | 24 | midiPlayerFactory({ json }); 25 | 26 | expect(midiFileSlicerFactory).to.have.been.calledOnce; 27 | expect(midiFileSlicerFactory).to.have.been.calledWithExactly(json); 28 | }); 29 | }); 30 | }); 31 | -------------------------------------------------------------------------------- /test/unit/factories/start-interval-scheduler.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from 'sinon'; 2 | import { createStartIntervalScheduler } from '../../../src/factories/start-interval-scheduler'; 3 | 4 | describe('createStartIntervalScheduler()', () => { 5 | let clearInterval; 6 | let performance; 7 | let setInterval; 8 | let startIntervalScheduler; 9 | 10 | beforeEach(() => { 11 | clearInterval = spy(); 12 | performance = { now: stub() }; 13 | setInterval = stub(); 14 | 15 | startIntervalScheduler = createStartIntervalScheduler(clearInterval, performance, setInterval); 16 | }); 17 | 18 | it('should return a function', () => { 19 | expect(startIntervalScheduler).to.be.a('function'); 20 | }); 21 | 22 | describe('startIntervalScheduler()', () => { 23 | let handler; 24 | let intervalId; 25 | let next; 26 | 27 | beforeEach(() => { 28 | next = spy(); 29 | 30 | performance.now.returns(3000); 31 | setInterval.callsFake((...args) => { 32 | [handler] = args; 33 | 34 | return intervalId; 35 | }); 36 | }); 37 | 38 | it('should call performance.now()', () => { 39 | startIntervalScheduler(next); 40 | 41 | expect(performance.now).to.have.been.calledOnceWithExactly(); 42 | }); 43 | 44 | it('should call setInterval()', () => { 45 | startIntervalScheduler(next); 46 | 47 | expect(setInterval).to.have.been.calledOnceWithExactly(handler, 50); 48 | expect(handler).to.be.a('function'); 49 | }); 50 | 51 | it('should call next()', () => { 52 | startIntervalScheduler(next); 53 | 54 | expect(next).to.have.been.calledOnceWithExactly({ end: 4000, start: 3000 }); 55 | }); 56 | 57 | it('should not call next() when invoking the handler within the interval', () => { 58 | startIntervalScheduler(next); 59 | 60 | next.resetHistory(); 61 | performance.now.resetHistory(); 62 | performance.now.returns(3400); 63 | 64 | handler(); 65 | 66 | expect(next).to.have.not.been.called; 67 | expect(performance.now).to.have.been.calledOnceWithExactly(); 68 | }); 69 | 70 | it('should call next() when invoking the handler after the interval', () => { 71 | startIntervalScheduler(next); 72 | 73 | next.resetHistory(); 74 | performance.now.resetHistory(); 75 | performance.now.returns(3500); 76 | 77 | handler(); 78 | 79 | expect(next).to.have.been.calledOnceWithExactly({ end: 4500, start: 4000 }); 80 | expect(performance.now).to.have.been.calledOnceWithExactly(); 81 | }); 82 | 83 | it('should return an array with two functions', () => { 84 | const array = startIntervalScheduler(next); 85 | 86 | expect(array.length).to.equal(2); 87 | 88 | const [peekScheduler, stopScheduler] = array; 89 | 90 | expect(peekScheduler).to.be.a('function'); 91 | expect(stopScheduler).to.be.a('function'); 92 | }); 93 | 94 | describe('peekScheduler()', () => { 95 | let peekScheduler; 96 | 97 | beforeEach(() => { 98 | [peekScheduler] = startIntervalScheduler(next); 99 | 100 | performance.now.resetHistory(); 101 | performance.now.returns(4000); 102 | }); 103 | 104 | it('should call performance.now()', () => { 105 | peekScheduler(); 106 | 107 | expect(performance.now).to.have.been.calledOnceWithExactly(); 108 | }); 109 | 110 | it('should return the time returned by performance.now()', () => { 111 | expect(peekScheduler()).to.equal(4000); 112 | }); 113 | }); 114 | 115 | describe('stopScheduler()', () => { 116 | let stopScheduler; 117 | 118 | beforeEach(() => { 119 | [, stopScheduler] = startIntervalScheduler(next); 120 | }); 121 | 122 | it('should call clearInterval()', () => { 123 | stopScheduler(); 124 | 125 | expect(clearInterval).to.have.been.calledOnceWithExactly(intervalId); 126 | }); 127 | 128 | it('should return undefined', () => { 129 | expect(stopScheduler()).to.be.undefined; 130 | }); 131 | }); 132 | }); 133 | }); 134 | -------------------------------------------------------------------------------- /test/unit/factories/start-timeout-scheduler.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from 'sinon'; 2 | import { createStartTimeoutScheduler } from '../../../src/factories/start-timeout-scheduler'; 3 | 4 | describe('createStartTimeoutScheduler()', () => { 5 | let clearTimeout; 6 | let setTimeout; 7 | let startTimeoutScheduler; 8 | 9 | beforeEach(() => { 10 | clearTimeout = spy(); 11 | setTimeout = stub(); 12 | 13 | startTimeoutScheduler = createStartTimeoutScheduler(clearTimeout, setTimeout); 14 | }); 15 | 16 | it('should return a function', () => { 17 | expect(startTimeoutScheduler).to.be.a('function'); 18 | }); 19 | 20 | describe('startTimeoutScheduler()', () => { 21 | let handler; 22 | let timeout; 23 | let timeoutId; 24 | 25 | beforeEach(() => { 26 | handler = 'a fake handler'; 27 | timeout = 50; 28 | 29 | setTimeout.returns(timeoutId); 30 | }); 31 | 32 | it('should call setTimeout()', () => { 33 | startTimeoutScheduler(handler, timeout); 34 | 35 | expect(setTimeout).to.have.been.calledOnceWithExactly(handler, timeout); 36 | }); 37 | 38 | it('should return a function', () => { 39 | expect(startTimeoutScheduler(handler, timeout)).to.be.a('function'); 40 | }); 41 | 42 | describe('stopScheduler()', () => { 43 | let stopScheduler; 44 | 45 | beforeEach(() => { 46 | stopScheduler = startTimeoutScheduler(handler, timeout); 47 | }); 48 | 49 | it('should call clearTimeout()', () => { 50 | stopScheduler(); 51 | 52 | expect(clearTimeout).to.have.been.calledOnceWithExactly(timeoutId); 53 | }); 54 | 55 | it('should return undefined', () => { 56 | expect(stopScheduler()).to.be.undefined; 57 | }); 58 | }); 59 | }); 60 | }); 61 | -------------------------------------------------------------------------------- /test/unit/helpers/encode-midi-message.js: -------------------------------------------------------------------------------- 1 | import { encodeMidiMessage } from '../../../src/helpers/encode-midi-message'; 2 | 3 | describe('encodeMidiMessage()', () => { 4 | it('should encode a control change message', () => { 5 | const sequence = encodeMidiMessage({ 6 | channel: 3, 7 | controlChange: { 8 | type: 16, 9 | value: 127 10 | } 11 | }); 12 | 13 | expect(sequence).to.deep.equal(new Uint8Array([0xb3, 0x10, 0x7f])); 14 | }); 15 | 16 | it('should encode a note off message', () => { 17 | const sequence = encodeMidiMessage({ 18 | channel: 3, 19 | noteOff: { 20 | noteNumber: 71, 21 | velocity: 127 22 | } 23 | }); 24 | 25 | expect(sequence).to.deep.equal(new Uint8Array([0x83, 0x47, 0x7f])); 26 | }); 27 | 28 | it('should encode a note on message', () => { 29 | const sequence = encodeMidiMessage({ 30 | channel: 3, 31 | noteOn: { 32 | noteNumber: 71, 33 | velocity: 127 34 | } 35 | }); 36 | 37 | expect(sequence).to.deep.equal(new Uint8Array([0x93, 0x47, 0x7f])); 38 | }); 39 | 40 | it('should encode a program change message', () => { 41 | const sequence = encodeMidiMessage({ 42 | channel: 3, 43 | programChange: { 44 | programNumber: 49 45 | } 46 | }); 47 | 48 | expect(sequence).to.deep.equal(new Uint8Array([0xc3, 0x31])); 49 | }); 50 | }); 51 | -------------------------------------------------------------------------------- /test/unit/midi-player.js: -------------------------------------------------------------------------------- 1 | import { spy, stub } from 'sinon'; 2 | import { MidiPlayer } from '../../src/midi-player'; 3 | import { midiFileSlicerMock } from '../mock/midi-file-slicer'; 4 | import { midiOutputMock } from '../mock/midi-output'; 5 | import { performanceMock } from '../mock/performance'; 6 | 7 | describe('MidiPlayer', () => { 8 | let encodeMidiMessage; 9 | let filterMidiMessage; 10 | let handler; 11 | let json; 12 | let midiPlayer; 13 | let next; 14 | let sequence; 15 | let startIntervalScheduler; 16 | let startTimeoutScheduler; 17 | let stopScheduler; 18 | 19 | beforeEach(() => { 20 | encodeMidiMessage = stub(); 21 | filterMidiMessage = stub(); 22 | startIntervalScheduler = stub(); 23 | startTimeoutScheduler = stub(); 24 | stopScheduler = spy(); 25 | 26 | json = { 27 | tracks: ['a fake track'] 28 | }; 29 | 30 | midiPlayer = new MidiPlayer({ 31 | encodeMidiMessage, 32 | filterMidiMessage, 33 | json, 34 | midiFileSlicer: midiFileSlicerMock, 35 | midiOutput: midiOutputMock, 36 | startIntervalScheduler, 37 | startTimeoutScheduler 38 | }); 39 | 40 | sequence = 'a fake sequence'; 41 | 42 | midiFileSlicerMock.slice.resetHistory(); 43 | midiOutputMock.clear.resetHistory(); 44 | midiOutputMock.send.resetHistory(); 45 | performanceMock.now.resetHistory(); 46 | 47 | encodeMidiMessage.returns(sequence); 48 | filterMidiMessage.returns(true); 49 | performanceMock.now.returns(200); 50 | startIntervalScheduler.callsFake((...args) => { 51 | [next] = args; 52 | 53 | const start = performanceMock.now(); 54 | 55 | next({ end: start + 1000, start }); 56 | 57 | return [() => performanceMock.now(), stopScheduler]; 58 | }); 59 | startTimeoutScheduler.callsFake((...args) => { 60 | [handler] = args; 61 | 62 | return stopScheduler; 63 | }); 64 | }); 65 | 66 | describe('position', () => { 67 | describe('when not playing', () => { 68 | it('should return zero', () => { 69 | expect(midiPlayer.position).to.equal(0); 70 | }); 71 | }); 72 | 73 | describe('when playing', () => { 74 | beforeEach(() => { 75 | midiFileSlicerMock.slice.returns([ 76 | { 77 | event: { 78 | noteOn: 'a fake note on event' 79 | }, 80 | time: 500 81 | } 82 | ]); 83 | 84 | midiPlayer.play(); 85 | }); 86 | 87 | describe('without any elapsed time', () => { 88 | it('should return zero', () => { 89 | expect(midiPlayer.position).to.equal(0); 90 | }); 91 | }); 92 | 93 | describe('after 500 milliseconds', () => { 94 | beforeEach(() => { 95 | performanceMock.now.returns(500); 96 | }); 97 | 98 | it('should return the elapsed time', () => { 99 | expect(midiPlayer.position).to.equal(300); 100 | }); 101 | }); 102 | }); 103 | 104 | describe('when paused', () => { 105 | beforeEach(() => { 106 | midiFileSlicerMock.slice.returns([ 107 | { 108 | event: { 109 | noteOn: 'a fake note on event' 110 | }, 111 | time: 500 112 | } 113 | ]); 114 | 115 | midiPlayer.play(); 116 | }); 117 | 118 | describe('without any elapsed time', () => { 119 | beforeEach(() => { 120 | midiPlayer.pause(); 121 | }); 122 | 123 | it('should return zero', () => { 124 | expect(midiPlayer.position).to.equal(0); 125 | }); 126 | }); 127 | 128 | describe('after 500 milliseconds', () => { 129 | beforeEach(() => { 130 | performanceMock.now.returns(500); 131 | 132 | midiPlayer.pause(); 133 | }); 134 | 135 | it('should return the elapsed time', () => { 136 | expect(midiPlayer.position).to.equal(300); 137 | }); 138 | }); 139 | }); 140 | 141 | describe('when ended', () => { 142 | beforeEach(() => { 143 | midiFileSlicerMock.slice.returns([{ event: { delta: 0, endOfTrack: true }, time: 0 }]); 144 | 145 | midiPlayer.play(); 146 | }); 147 | 148 | it('should return zero', () => { 149 | expect(midiPlayer.position).to.equal(0); 150 | }); 151 | }); 152 | }); 153 | 154 | describe('pause()', () => { 155 | describe('when not playing', () => { 156 | it('should throw an error', () => { 157 | expect(() => midiPlayer.pause()).to.throw(Error, 'The player is not playing.'); 158 | }); 159 | }); 160 | 161 | describe('when playing', () => { 162 | let then; 163 | 164 | beforeEach(() => { 165 | then = spy(); 166 | 167 | midiFileSlicerMock.slice.returns([ 168 | { 169 | event: { 170 | noteOn: 'a fake note on event' 171 | }, 172 | time: 500 173 | } 174 | ]); 175 | 176 | midiPlayer.play().then(then); 177 | 178 | midiOutputMock.clear.resetHistory(); 179 | midiOutputMock.send.resetHistory(); 180 | }); 181 | 182 | it('should call clear() on the midiOutput', () => { 183 | midiPlayer.pause(); 184 | 185 | expect(midiOutputMock.clear).to.have.been.calledOnce; 186 | expect(midiOutputMock.clear).to.have.been.calledWithExactly(); 187 | }); 188 | 189 | it('should call send() on the midiOutput', () => { 190 | midiPlayer.pause(); 191 | 192 | expect(midiOutputMock.send).to.have.been.callCount(16); 193 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([176, 120, 0])); 194 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([177, 120, 0])); 195 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([178, 120, 0])); 196 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([179, 120, 0])); 197 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([180, 120, 0])); 198 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([181, 120, 0])); 199 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([182, 120, 0])); 200 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([183, 120, 0])); 201 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([184, 120, 0])); 202 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([185, 120, 0])); 203 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([186, 120, 0])); 204 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([187, 120, 0])); 205 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([188, 120, 0])); 206 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([189, 120, 0])); 207 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([190, 120, 0])); 208 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([191, 120, 0])); 209 | }); 210 | 211 | it('should call stopScheduler()', () => { 212 | midiPlayer.pause(); 213 | 214 | expect(stopScheduler).to.have.been.calledOnce; 215 | expect(stopScheduler).to.have.been.calledWithExactly(); 216 | }); 217 | 218 | it('should return undefined', () => { 219 | expect(midiPlayer.pause()).to.be.undefined; 220 | }); 221 | 222 | it('should resolve the promise returned by play()', async () => { 223 | midiPlayer.pause(); 224 | 225 | expect(then).to.have.not.been.called; 226 | 227 | await Promise.resolve(); 228 | 229 | expect(then).to.have.been.calledOnce; 230 | }); 231 | }); 232 | 233 | describe('when paused', () => { 234 | beforeEach(() => { 235 | midiFileSlicerMock.slice.returns([ 236 | { 237 | event: { 238 | noteOn: 'a fake note on event' 239 | }, 240 | time: 500 241 | } 242 | ]); 243 | 244 | midiPlayer.play(); 245 | midiPlayer.pause(); 246 | }); 247 | 248 | it('should throw an error', () => { 249 | expect(() => midiPlayer.pause()).to.throw(Error, 'The player is not playing.'); 250 | }); 251 | }); 252 | 253 | describe('when ended', () => { 254 | beforeEach(() => { 255 | midiFileSlicerMock.slice.returns([{ event: { delta: 0, endOfTrack: true }, time: 0 }]); 256 | 257 | midiPlayer.play(); 258 | }); 259 | 260 | it('should throw an error', () => { 261 | expect(() => midiPlayer.pause()).to.throw(Error, 'The player is not playing.'); 262 | }); 263 | }); 264 | }); 265 | 266 | describe('play()', () => { 267 | describe('when not playing', () => { 268 | describe('with a song not ending within the next interval', () => { 269 | let event; 270 | 271 | beforeEach(() => { 272 | event = { 273 | noteOn: 'a fake note on event' 274 | }; 275 | 276 | midiFileSlicerMock.slice.returns([{ event, time: 500 }]); 277 | }); 278 | 279 | it('should schedule all events up to the lookahead', () => { 280 | midiPlayer.play(); 281 | 282 | expect(startIntervalScheduler).to.have.been.calledOnce; 283 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 284 | 285 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 286 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 287 | 288 | expect(filterMidiMessage).to.have.been.calledOnce; 289 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 290 | 291 | expect(encodeMidiMessage).to.have.been.calledOnce; 292 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 293 | 294 | expect(midiOutputMock.send).to.have.been.calledOnce; 295 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 700); 296 | 297 | expect(stopScheduler).to.have.not.been.called; 298 | 299 | expect(startTimeoutScheduler).to.have.not.been.called; 300 | }); 301 | 302 | it('should return an unresolved promise', async () => { 303 | const then = spy(); 304 | 305 | midiPlayer.play().then(then); 306 | 307 | await Promise.resolve(); 308 | 309 | expect(then).to.have.not.been.called; 310 | }); 311 | }); 312 | 313 | describe('with a song ending at the start of the next interval', () => { 314 | let event; 315 | 316 | beforeEach(() => { 317 | event = { 318 | endOfTrack: true 319 | }; 320 | 321 | midiFileSlicerMock.slice.returns([{ event, time: 0 }]); 322 | }); 323 | 324 | it('should schedule all events up to the lookahead', () => { 325 | midiPlayer.play(); 326 | 327 | expect(startIntervalScheduler).to.have.been.calledOnce; 328 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 329 | 330 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 331 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 332 | 333 | expect(filterMidiMessage).to.have.been.calledOnce; 334 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 335 | 336 | expect(encodeMidiMessage).to.have.been.calledOnce; 337 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 338 | 339 | expect(midiOutputMock.send).to.have.been.calledOnce; 340 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 200); 341 | 342 | expect(stopScheduler).to.have.been.calledOnce; 343 | expect(stopScheduler).to.have.been.calledWithExactly(); 344 | 345 | expect(startTimeoutScheduler).to.have.not.been.called; 346 | }); 347 | 348 | it('should return a resolved promise', async () => { 349 | const then = spy(); 350 | 351 | midiPlayer.play().then(then); 352 | 353 | await Promise.resolve(); 354 | 355 | expect(then).to.have.been.calledOnce; 356 | }); 357 | }); 358 | 359 | describe('with a song ending in the middle of the current interval', () => { 360 | let event; 361 | 362 | beforeEach(() => { 363 | event = { 364 | endOfTrack: true 365 | }; 366 | 367 | midiFileSlicerMock.slice.returns([{ event, time: 500 }]); 368 | }); 369 | 370 | it('should schedule all events up to the lookahead', () => { 371 | midiPlayer.play(); 372 | 373 | expect(startIntervalScheduler).to.have.been.calledOnce; 374 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 375 | 376 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 377 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 378 | 379 | expect(filterMidiMessage).to.have.been.calledOnce; 380 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 381 | 382 | expect(encodeMidiMessage).to.have.been.calledOnce; 383 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 384 | 385 | expect(midiOutputMock.send).to.have.been.calledOnce; 386 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 700); 387 | 388 | expect(stopScheduler).to.have.been.calledOnce; 389 | expect(stopScheduler).to.have.been.calledWithExactly(); 390 | 391 | expect(startTimeoutScheduler).to.have.been.calledOnce; 392 | expect(startTimeoutScheduler).to.have.been.calledWithExactly(handler, 500); 393 | }); 394 | 395 | it('should return a promise which resolves when the handler gets called', async () => { 396 | const then = spy(); 397 | 398 | midiPlayer.play().then(then); 399 | 400 | await Promise.resolve(); 401 | 402 | expect(then).to.have.not.been.called; 403 | 404 | handler(); 405 | 406 | await Promise.resolve(); 407 | 408 | expect(then).to.have.been.calledOnce; 409 | }); 410 | }); 411 | }); 412 | 413 | describe('when playing', () => { 414 | beforeEach(() => { 415 | midiFileSlicerMock.slice.returns([ 416 | { 417 | event: { 418 | noteOn: 'a fake note on event' 419 | }, 420 | time: 500 421 | } 422 | ]); 423 | 424 | midiPlayer.play(); 425 | }); 426 | 427 | it('should throw an error', () => { 428 | expect(() => midiPlayer.play()).to.throw(Error, 'The player is not stopped.'); 429 | }); 430 | }); 431 | 432 | describe('when paused', () => { 433 | beforeEach(() => { 434 | midiFileSlicerMock.slice.returns([ 435 | { 436 | event: { 437 | noteOn: 'a fake note on event' 438 | }, 439 | time: 500 440 | } 441 | ]); 442 | 443 | midiPlayer.play(); 444 | midiPlayer.pause(); 445 | }); 446 | 447 | it('should throw an error', () => { 448 | expect(() => midiPlayer.play()).to.throw(Error, 'The player is not stopped.'); 449 | }); 450 | }); 451 | 452 | describe('when ended', () => { 453 | beforeEach(() => { 454 | midiFileSlicerMock.slice.returns([{ event: { delta: 0, endOfTrack: true }, time: 0 }]); 455 | 456 | performanceMock.now.returns(1200); 457 | 458 | encodeMidiMessage.resetHistory(); 459 | filterMidiMessage.resetHistory(); 460 | midiFileSlicerMock.slice.resetHistory(); 461 | midiOutputMock.send.resetHistory(); 462 | startIntervalScheduler.resetHistory(); 463 | stopScheduler.resetHistory(); 464 | }); 465 | 466 | describe('with a song not ending within the next interval', () => { 467 | let event; 468 | 469 | beforeEach(() => { 470 | event = { 471 | noteOn: 'a fake note on event' 472 | }; 473 | 474 | midiFileSlicerMock.slice.returns([{ event, time: 500 }]); 475 | }); 476 | 477 | it('should schedule all events up to the lookahead', () => { 478 | midiPlayer.play(); 479 | 480 | expect(startIntervalScheduler).to.have.been.calledOnce; 481 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 482 | 483 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 484 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 485 | 486 | expect(filterMidiMessage).to.have.been.calledOnce; 487 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 488 | 489 | expect(encodeMidiMessage).to.have.been.calledOnce; 490 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 491 | 492 | expect(midiOutputMock.send).to.have.been.calledOnce; 493 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 1700); 494 | 495 | expect(stopScheduler).to.have.not.been.called; 496 | 497 | expect(startTimeoutScheduler).to.have.not.been.called; 498 | }); 499 | 500 | it('should return an unresolved promise', async () => { 501 | const then = spy(); 502 | 503 | midiPlayer.play().then(then); 504 | 505 | await Promise.resolve(); 506 | 507 | expect(then).to.have.not.been.called; 508 | }); 509 | }); 510 | 511 | describe('with a song ending at the start of the next interval', () => { 512 | let event; 513 | 514 | beforeEach(() => { 515 | event = { 516 | endOfTrack: true 517 | }; 518 | 519 | midiFileSlicerMock.slice.returns([{ event, time: 0 }]); 520 | }); 521 | 522 | it('should schedule all events up to the lookahead', () => { 523 | midiPlayer.play(); 524 | 525 | expect(startIntervalScheduler).to.have.been.calledOnce; 526 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 527 | 528 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 529 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 530 | 531 | expect(filterMidiMessage).to.have.been.calledOnce; 532 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 533 | 534 | expect(encodeMidiMessage).to.have.been.calledOnce; 535 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 536 | 537 | expect(midiOutputMock.send).to.have.been.calledOnce; 538 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 1200); 539 | 540 | expect(stopScheduler).to.have.been.calledOnce; 541 | expect(stopScheduler).to.have.been.calledWithExactly(); 542 | 543 | expect(startTimeoutScheduler).to.have.not.been.called; 544 | }); 545 | 546 | it('should return a resolved promise', async () => { 547 | const then = spy(); 548 | 549 | midiPlayer.play().then(then); 550 | 551 | await Promise.resolve(); 552 | 553 | expect(then).to.have.been.calledOnce; 554 | }); 555 | }); 556 | 557 | describe('with a song ending in the middle of the current interval', () => { 558 | let event; 559 | 560 | beforeEach(() => { 561 | event = { 562 | endOfTrack: true 563 | }; 564 | 565 | midiFileSlicerMock.slice.returns([{ event, time: 500 }]); 566 | }); 567 | 568 | it('should schedule all events up to the lookahead', () => { 569 | midiPlayer.play(); 570 | 571 | expect(startIntervalScheduler).to.have.been.calledOnce; 572 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 573 | 574 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 575 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 576 | 577 | expect(filterMidiMessage).to.have.been.calledOnce; 578 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 579 | 580 | expect(encodeMidiMessage).to.have.been.calledOnce; 581 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 582 | 583 | expect(midiOutputMock.send).to.have.been.calledOnce; 584 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 1700); 585 | 586 | expect(stopScheduler).to.have.been.calledOnce; 587 | expect(stopScheduler).to.have.been.calledWithExactly(); 588 | 589 | expect(startTimeoutScheduler).to.have.been.calledOnce; 590 | expect(startTimeoutScheduler).to.have.been.calledWithExactly(handler, 500); 591 | }); 592 | 593 | it('should return a promise which resolves when the handler gets called', async () => { 594 | const then = spy(); 595 | 596 | midiPlayer.play().then(then); 597 | 598 | await Promise.resolve(); 599 | 600 | expect(then).to.have.not.been.called; 601 | 602 | handler(); 603 | 604 | await Promise.resolve(); 605 | 606 | expect(then).to.have.been.calledOnce; 607 | }); 608 | }); 609 | }); 610 | }); 611 | 612 | describe('resume()', () => { 613 | describe('when not playing', () => { 614 | it('should throw an error', () => { 615 | expect(() => midiPlayer.resume()).to.throw(Error, 'The player is not paused.'); 616 | }); 617 | }); 618 | 619 | describe('when playing', () => { 620 | beforeEach(() => { 621 | midiFileSlicerMock.slice.returns([ 622 | { 623 | event: { 624 | noteOn: 'a fake note on event' 625 | }, 626 | time: 500 627 | } 628 | ]); 629 | 630 | midiPlayer.play(); 631 | }); 632 | 633 | it('should throw an error', () => { 634 | expect(() => midiPlayer.resume()).to.throw(Error, 'The player is not paused.'); 635 | }); 636 | }); 637 | 638 | describe('when paused', () => { 639 | describe('with a song not ending within the next interval', () => { 640 | let event; 641 | 642 | beforeEach(() => { 643 | event = { 644 | noteOn: 'another fake note on event' 645 | }; 646 | 647 | midiFileSlicerMock.slice.returns([{ event, time: 500 }]); 648 | 649 | midiPlayer.play(); 650 | midiPlayer.pause(); 651 | 652 | encodeMidiMessage.resetHistory(); 653 | filterMidiMessage.resetHistory(); 654 | midiFileSlicerMock.slice.resetHistory(); 655 | midiOutputMock.send.resetHistory(); 656 | startIntervalScheduler.resetHistory(); 657 | stopScheduler.resetHistory(); 658 | }); 659 | 660 | it('should schedule all events up to the lookahead', () => { 661 | midiPlayer.resume(); 662 | 663 | expect(startIntervalScheduler).to.have.been.calledOnce; 664 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 665 | 666 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 667 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(0, 1000); 668 | 669 | expect(filterMidiMessage).to.have.been.calledOnce; 670 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 671 | 672 | expect(encodeMidiMessage).to.have.been.calledOnce; 673 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 674 | 675 | expect(midiOutputMock.send).to.have.been.calledOnce; 676 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 700); 677 | 678 | expect(stopScheduler).to.have.not.been.called; 679 | 680 | expect(startTimeoutScheduler).to.have.not.been.called; 681 | }); 682 | 683 | it('should return an unresolved promise', async () => { 684 | const then = spy(); 685 | 686 | midiPlayer.resume().then(then); 687 | 688 | await Promise.resolve(); 689 | 690 | expect(then).to.have.not.been.called; 691 | }); 692 | }); 693 | 694 | describe('with a song ending at the start of the next interval', () => { 695 | let event; 696 | 697 | beforeEach(() => { 698 | event = { endOfTrack: true }; 699 | 700 | midiFileSlicerMock.slice.returns([ 701 | { 702 | event: { 703 | noteOn: 'another fake note on event' 704 | }, 705 | time: 500 706 | } 707 | ]); 708 | 709 | midiPlayer.play(); 710 | 711 | performanceMock.now.returns(1200); 712 | 713 | midiPlayer.pause(); 714 | 715 | encodeMidiMessage.resetHistory(); 716 | filterMidiMessage.resetHistory(); 717 | midiFileSlicerMock.slice.resetHistory(); 718 | midiOutputMock.send.resetHistory(); 719 | startIntervalScheduler.resetHistory(); 720 | stopScheduler.resetHistory(); 721 | 722 | midiFileSlicerMock.slice.returns([{ event, time: 0 }]); 723 | }); 724 | 725 | it('should schedule all events up to the lookahead', () => { 726 | midiPlayer.resume(); 727 | 728 | expect(startIntervalScheduler).to.have.been.calledOnce; 729 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 730 | 731 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 732 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(1000, 2000); 733 | 734 | expect(filterMidiMessage).to.have.been.calledOnce; 735 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 736 | 737 | expect(encodeMidiMessage).to.have.been.calledOnce; 738 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 739 | 740 | expect(midiOutputMock.send).to.have.been.calledOnce; 741 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 1200); 742 | 743 | expect(stopScheduler).to.have.been.calledOnce; 744 | expect(stopScheduler).to.have.been.calledWithExactly(); 745 | 746 | expect(startTimeoutScheduler).to.have.not.been.called; 747 | }); 748 | 749 | it('should return a resolved promise', async () => { 750 | const then = spy(); 751 | 752 | midiPlayer.resume().then(then); 753 | 754 | await Promise.resolve(); 755 | 756 | expect(then).to.have.been.calledOnce; 757 | }); 758 | }); 759 | 760 | describe('with a song ending in the middle of the current interval', () => { 761 | let event; 762 | 763 | beforeEach(() => { 764 | event = { endOfTrack: true }; 765 | 766 | midiFileSlicerMock.slice.returns([ 767 | { 768 | event: { 769 | noteOn: 'another fake note on event' 770 | }, 771 | time: 500 772 | } 773 | ]); 774 | 775 | midiPlayer.play(); 776 | 777 | performanceMock.now.returns(1200); 778 | 779 | midiPlayer.pause(); 780 | 781 | encodeMidiMessage.resetHistory(); 782 | filterMidiMessage.resetHistory(); 783 | midiFileSlicerMock.slice.resetHistory(); 784 | midiOutputMock.send.resetHistory(); 785 | startIntervalScheduler.resetHistory(); 786 | stopScheduler.resetHistory(); 787 | 788 | midiFileSlicerMock.slice.returns([{ event, time: 500 }]); 789 | }); 790 | 791 | it('should schedule all events up to the lookahead', () => { 792 | midiPlayer.resume(); 793 | 794 | expect(startIntervalScheduler).to.have.been.calledOnce; 795 | expect(startIntervalScheduler).to.have.been.calledWithExactly(next); 796 | 797 | expect(midiFileSlicerMock.slice).to.have.been.calledOnce; 798 | expect(midiFileSlicerMock.slice).to.have.been.calledWithExactly(1000, 2000); 799 | 800 | expect(filterMidiMessage).to.have.been.calledOnce; 801 | expect(filterMidiMessage).to.have.been.calledWithExactly(event); 802 | 803 | expect(encodeMidiMessage).to.have.been.calledOnce; 804 | expect(encodeMidiMessage).to.have.been.calledWithExactly(event); 805 | 806 | expect(midiOutputMock.send).to.have.been.calledOnce; 807 | expect(midiOutputMock.send).to.have.been.calledWithExactly(sequence, 1700); 808 | 809 | expect(stopScheduler).to.have.been.calledOnce; 810 | expect(stopScheduler).to.have.been.calledWithExactly(); 811 | 812 | expect(startTimeoutScheduler).to.have.been.calledOnce; 813 | expect(startTimeoutScheduler).to.have.been.calledWithExactly(handler, 500); 814 | }); 815 | 816 | it('should return a promise which resolves when the handler gets called', async () => { 817 | const then = spy(); 818 | 819 | midiPlayer.resume().then(then); 820 | 821 | await Promise.resolve(); 822 | 823 | expect(then).to.have.not.been.called; 824 | 825 | handler(); 826 | 827 | await Promise.resolve(); 828 | 829 | expect(then).to.have.been.calledOnce; 830 | }); 831 | }); 832 | }); 833 | 834 | describe('when ended', () => { 835 | beforeEach(() => { 836 | midiFileSlicerMock.slice.returns([{ event: { delta: 0, endOfTrack: true }, time: 0 }]); 837 | 838 | midiPlayer.play(); 839 | }); 840 | 841 | it('should throw an error', () => { 842 | expect(() => midiPlayer.resume()).to.throw(Error, 'The player is not paused.'); 843 | }); 844 | }); 845 | }); 846 | 847 | describe('stop()', () => { 848 | describe('when not playing', () => { 849 | it('should throw an error', () => { 850 | expect(() => midiPlayer.stop()).to.throw(Error, 'The player is already stopped.'); 851 | }); 852 | }); 853 | 854 | describe('when playing', () => { 855 | let then; 856 | 857 | beforeEach(() => { 858 | then = spy(); 859 | 860 | midiFileSlicerMock.slice.returns([ 861 | { 862 | event: { 863 | noteOn: 'a fake note on event' 864 | }, 865 | time: 500 866 | } 867 | ]); 868 | 869 | midiPlayer.play().then(then); 870 | 871 | midiOutputMock.clear.resetHistory(); 872 | midiOutputMock.send.resetHistory(); 873 | }); 874 | 875 | it('should call clear() on the midiOutput', () => { 876 | midiPlayer.stop(); 877 | 878 | expect(midiOutputMock.clear).to.have.been.calledOnce; 879 | expect(midiOutputMock.clear).to.have.been.calledWithExactly(); 880 | }); 881 | 882 | it('should call send() on the midiOutput', () => { 883 | midiPlayer.stop(); 884 | 885 | expect(midiOutputMock.send).to.have.been.callCount(16); 886 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([176, 120, 0])); 887 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([177, 120, 0])); 888 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([178, 120, 0])); 889 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([179, 120, 0])); 890 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([180, 120, 0])); 891 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([181, 120, 0])); 892 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([182, 120, 0])); 893 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([183, 120, 0])); 894 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([184, 120, 0])); 895 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([185, 120, 0])); 896 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([186, 120, 0])); 897 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([187, 120, 0])); 898 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([188, 120, 0])); 899 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([189, 120, 0])); 900 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([190, 120, 0])); 901 | expect(midiOutputMock.send).to.have.been.calledWithExactly(new Uint8Array([191, 120, 0])); 902 | }); 903 | 904 | it('should call stopScheduler()', () => { 905 | midiPlayer.stop(); 906 | 907 | expect(stopScheduler).to.have.been.calledOnce; 908 | expect(stopScheduler).to.have.been.calledWithExactly(); 909 | }); 910 | 911 | it('should return undefined', () => { 912 | expect(midiPlayer.stop()).to.be.undefined; 913 | }); 914 | 915 | it('should resolve the promise returned by play()', async () => { 916 | midiPlayer.stop(); 917 | 918 | expect(then).to.have.not.been.called; 919 | 920 | await Promise.resolve(); 921 | 922 | expect(then).to.have.been.calledOnce; 923 | }); 924 | }); 925 | 926 | describe('when paused', () => { 927 | beforeEach(() => { 928 | midiFileSlicerMock.slice.returns([ 929 | { 930 | event: { 931 | noteOn: 'a fake note on event' 932 | }, 933 | time: 500 934 | } 935 | ]); 936 | 937 | midiPlayer.play(); 938 | midiPlayer.pause(); 939 | 940 | midiOutputMock.clear.resetHistory(); 941 | midiOutputMock.send.resetHistory(); 942 | stopScheduler.resetHistory(); 943 | }); 944 | 945 | it('should not call clear() on the midiOutput', () => { 946 | midiPlayer.stop(); 947 | 948 | expect(midiOutputMock.clear).to.have.not.been.called; 949 | }); 950 | 951 | it('should not call send() on the midiOutput', () => { 952 | midiPlayer.stop(); 953 | 954 | expect(midiOutputMock.send).to.have.not.been.called; 955 | }); 956 | 957 | it('should not call stopScheduler()', () => { 958 | midiPlayer.stop(); 959 | 960 | expect(stopScheduler).to.have.not.been.called; 961 | }); 962 | 963 | it('should return undefined', () => { 964 | expect(midiPlayer.stop()).to.be.undefined; 965 | }); 966 | }); 967 | 968 | describe('when ended', () => { 969 | beforeEach(() => { 970 | midiFileSlicerMock.slice.returns([{ event: { delta: 0, endOfTrack: true }, time: 0 }]); 971 | 972 | midiPlayer.play(); 973 | }); 974 | 975 | it('should throw an error', () => { 976 | expect(() => midiPlayer.stop()).to.throw(Error, 'The player is already stopped.'); 977 | }); 978 | }); 979 | }); 980 | }); 981 | --------------------------------------------------------------------------------