├── .eslintrc ├── .github └── workflows │ └── package.yml ├── .gitignore ├── .releaserc ├── PAGE.md ├── README.md ├── icon.svg ├── package-lock.json ├── package.json ├── plugin.json ├── scripts └── bump-manifest.ts ├── src └── index.ts └── tsconfig.json /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es2020": true 5 | }, 6 | "extends": [ 7 | "plugin:prettier/recommended", 8 | "plugin:@typescript-eslint/eslint-recommended", 9 | "plugin:@typescript-eslint/recommended" 10 | ], 11 | "parser": "@typescript-eslint/parser", 12 | "parserOptions": { 13 | "ecmaVersion": 2020, 14 | "sourceType": "module" 15 | }, 16 | "plugins": ["@typescript-eslint", "prettier"], 17 | "rules": { 18 | "prettier/prettier": "warn" 19 | }, 20 | "settings": { 21 | "import/resolver": { 22 | "node": { 23 | "extensions": [".js", ".ts"] 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /.github/workflows/package.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - "main" 5 | 6 | jobs: 7 | build: 8 | name: Build and pack 9 | runs-on: ubuntu-latest 10 | steps: 11 | - uses: actions/checkout@v2 12 | - run: npm ci 13 | - run: npm run build 14 | - run: npx semantic-release 15 | env: 16 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 17 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | logs 3 | *.log 4 | npm-debug.log* 5 | yarn-debug.log* 6 | yarn-error.log* 7 | lerna-debug.log* 8 | 9 | # Diagnostic reports (https://nodejs.org/api/report.html) 10 | report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json 11 | 12 | # Runtime data 13 | pids 14 | *.pid 15 | *.seed 16 | *.pid.lock 17 | 18 | # Directory for instrumented libs generated by jscoverage/JSCover 19 | lib-cov 20 | 21 | # Coverage directory used by tools like istanbul 22 | coverage 23 | *.lcov 24 | 25 | # nyc test coverage 26 | .nyc_output 27 | 28 | # Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) 29 | .grunt 30 | 31 | # Bower dependency directory (https://bower.io/) 32 | bower_components 33 | 34 | # node-waf configuration 35 | .lock-wscript 36 | 37 | # Compiled binary addons (https://nodejs.org/api/addons.html) 38 | build/Release 39 | 40 | # Dependency directories 41 | node_modules/ 42 | jspm_packages/ 43 | 44 | # TypeScript v1 declaration files 45 | typings/ 46 | 47 | # TypeScript cache 48 | *.tsbuildinfo 49 | 50 | # Optional npm cache directory 51 | .npm 52 | 53 | # Optional eslint cache 54 | .eslintcache 55 | 56 | # Microbundle cache 57 | .rpt2_cache/ 58 | .rts2_cache_cjs/ 59 | .rts2_cache_es/ 60 | .rts2_cache_umd/ 61 | 62 | # Optional REPL history 63 | .node_repl_history 64 | 65 | # Output of 'npm pack' 66 | *.tgz 67 | 68 | # Yarn Integrity file 69 | .yarn-integrity 70 | 71 | # dotenv environment variables file 72 | .env 73 | .env.test 74 | 75 | # parcel-bundler cache (https://parceljs.org/) 76 | .cache 77 | 78 | # Next.js build output 79 | .next 80 | 81 | # Nuxt.js build / generate output 82 | .nuxt 83 | 84 | # Gatsby files 85 | .cache/ 86 | # Comment in the public line in if your project uses Gatsby and *not* Next.js 87 | # https://nextjs.org/blog/next-9-1#public-directory-support 88 | # public 89 | 90 | # vuepress build output 91 | .vuepress/dist 92 | 93 | # Serverless directories 94 | .serverless/ 95 | 96 | # FuseBox cache 97 | .fusebox/ 98 | 99 | # DynamoDB Local files 100 | .dynamodb/ 101 | 102 | # TernJS port file 103 | .tern-port 104 | 105 | # Build output 106 | lib/ 107 | *.midiMixerPlugin -------------------------------------------------------------------------------- /.releaserc: -------------------------------------------------------------------------------- 1 | { 2 | "branches": [ 3 | "main" 4 | ], 5 | "plugins": [ 6 | [ 7 | "@semantic-release/commit-analyzer", 8 | { 9 | "releaseRules": [ 10 | { 11 | "type": "breaking", 12 | "release": "major" 13 | }, 14 | { 15 | "type": "feat", 16 | "release": "minor" 17 | }, 18 | { 19 | "message": "*", 20 | "release": "patch" 21 | }, 22 | { 23 | "scope": "no-release", 24 | "release": false 25 | } 26 | ] 27 | } 28 | ], 29 | "@semantic-release/release-notes-generator", 30 | [ 31 | "@semantic-release/npm", 32 | { 33 | "npmPublish": false 34 | } 35 | ], 36 | [ 37 | "@semantic-release/github", 38 | { 39 | "assets": [ 40 | "*.midiMixerPlugin" 41 | ] 42 | } 43 | ], 44 | [ 45 | "@semantic-release/git", 46 | { 47 | "assets": [ 48 | "package.json", 49 | "package-lock.json", 50 | "plugin.json" 51 | ] 52 | } 53 | ] 54 | ] 55 | } 56 | -------------------------------------------------------------------------------- /PAGE.md: -------------------------------------------------------------------------------- 1 | ## This Requires the OBS Websocket version 5 2 | 3 | # OBS Plugin 4 | 5 | A proof-of-concept plugin that provides very basic OBS integration. 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # OBS Plugin 2 | 3 | Currently this is a proof-of-concept plugin to show some practical usage of [midi-mixer-plugin](https://github.com/midi-mixer/midi-mixer-plugin). It provides an exceedingly basic OBS integration by adding a list of all sources to your MIDI Mixer assignments list and button types for switching between scenes. 4 | 5 | ## Usage 6 | 7 | 1. Download and install [obs-websocket](https://obsproject.com/forum/resources/obs-websocket-remote-control-obs-studio-from-websockets.466/) for OBS 8 | 2. [Download the plugin](https://github.com/midi-mixer/plugin-obs/releases/latest) 9 | 3. Open the `.MidiMixerPlugin` file with midi mixer 10 | 11 | For more information on how this plugin was made (or to make your own) check out [midi-mixer-plugin](https://github.com/midi-mixer/midi-mixer-plugin) and the [midi-mixer/plugin-template](https://github.com/midi-mixer/plugin-template) repository. 12 | -------------------------------------------------------------------------------- /icon.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "com.midi-mixer.obs", 3 | "description": "An OBS integration for MIDI Mixer.", 4 | "scripts": { 5 | "test": "echo \"Error: no test specified\" && exit 1", 6 | "clean": "rimraf dist", 7 | "prestart": "npm run clean", 8 | "build": "tsc", 9 | "semantic-release": "semantic-release", 10 | "postversion": "npx ts-node scripts/bump-manifest.ts && midi-mixer pack", 11 | "pack": "midi-mixer pack" 12 | }, 13 | "files": [ 14 | "icon.svg", 15 | "PAGE.md", 16 | "plugin.json", 17 | "lib" 18 | ], 19 | "license": "ISC", 20 | "devDependencies": { 21 | "@semantic-release/exec": "6.0.3", 22 | "@semantic-release/git": "10.0.1", 23 | "@types/node": "17.0.5", 24 | "@typescript-eslint/eslint-plugin": "^4.14.2", 25 | "@typescript-eslint/parser": "^4.14.2", 26 | "eslint": "^7.19.0", 27 | "eslint-config-prettier": "^7.2.0", 28 | "eslint-plugin-import": "^2.22.1", 29 | "eslint-plugin-prettier": "^3.3.1", 30 | "midi-mixer-cli": "^1.0.5", 31 | "prettier": "^2.2.1", 32 | "rimraf": "^3.0.2", 33 | "semantic-release": "18.0.1", 34 | "typescript": "^4.1.3" 35 | }, 36 | "dependencies": { 37 | "midi-mixer-plugin": "^1.0.2", 38 | "obs-websocket-js": "^5.0.2", 39 | "ws": "8.4.0" 40 | }, 41 | "bundledDependencies": [ 42 | "midi-mixer-plugin", 43 | "obs-websocket-js", 44 | "ws" 45 | ], 46 | "volta": { 47 | "node": "14.15.4", 48 | "npm": "8.19.2" 49 | }, 50 | "version": "1.0.0", 51 | "bundleDependencies": [ 52 | "midi-mixer-plugin", 53 | "obs-websocket-js", 54 | "ws" 55 | ] 56 | } 57 | -------------------------------------------------------------------------------- /plugin.json: -------------------------------------------------------------------------------- 1 | { 2 | "$schema": "./node_modules/midi-mixer-plugin/plugin.schema.json", 3 | "id": "com.midi-mixer.obs", 4 | "name": "OBS", 5 | "type": "node", 6 | "version": "1.0.0", 7 | "author": "MIDI Mixer", 8 | "main": "lib/index.js", 9 | "icon": "icon.svg", 10 | "settings": { 11 | "address": { 12 | "label": "OBS WebSocket Address", 13 | "required": false, 14 | "type": "text", 15 | "fallback": "ws://localhost:4455" 16 | }, 17 | "password": { 18 | "label": "OBS WebSocket Password", 19 | "required": false, 20 | "type": "password" 21 | }, 22 | "status": { 23 | "label": "Status", 24 | "type": "status", 25 | "fallback": "Disconnected" 26 | }, 27 | "reconnect": { 28 | "label": "Reconnect", 29 | "type": "button" 30 | }, 31 | "meterMultiplier": { 32 | "label": "Meter Scaling Multiplier", 33 | "type": "text", 34 | "fallback": "1" 35 | } 36 | } 37 | } -------------------------------------------------------------------------------- /scripts/bump-manifest.ts: -------------------------------------------------------------------------------- 1 | import { writeFileSync } from "fs"; 2 | import { version } from "../package.json"; 3 | import manifest from "../plugin.json"; 4 | 5 | writeFileSync("plugin.json", JSON.stringify({ ...manifest, version }, null, 2)); 6 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import { Assignment, ButtonType } from "midi-mixer-plugin"; 2 | import OBSWebSocket, { OBSWebSocketError, EventSubscription, OBSEventTypes, OBSResponseTypes } from "obs-websocket-js"; 3 | 4 | interface Settings { 5 | address?: string; 6 | password?: string; 7 | meterMultiplier?: string 8 | meterScaling?: number 9 | } 10 | 11 | const obs = new OBSWebSocket(); 12 | let inputs: Record = {}; 13 | let scenes: Record = {}; 14 | const settingsP: Promise = $MM.getSettings(); 15 | let settings: Settings; 16 | 17 | const connect = async () => { 18 | settings = await settingsP; 19 | 20 | // We need to use a text string to allow decimals. If given value is not a valid number set it to 1 21 | if (!(settings.meterScaling = Number(settings.meterMultiplier))) { 22 | settings.meterScaling = 1; 23 | } 24 | 25 | let address = (settings.address ?? "ws://localhost:4455") 26 | if (!address.startsWith("ws://") && !address.startsWith("wss://")) { 27 | address = `ws://${address}`; 28 | } 29 | 30 | return obs.connect(address, settings.password ?? "", { eventSubscriptions: EventSubscription.All | EventSubscription.InputVolumeMeters }) 31 | }; 32 | 33 | 34 | const registerListeners = () => { 35 | obs.on("InputVolumeChanged", (data) => { 36 | const source = inputs[data.inputName]; 37 | if (!source) return; 38 | 39 | source.volume = data.inputVolumeMul; 40 | }); 41 | 42 | obs.on("InputMuteStateChanged", (data) => { 43 | const source = inputs[data.inputName]; 44 | if (!source) return; 45 | 46 | source.muted = data.inputMuted; 47 | }); 48 | 49 | // TODO: Test this thoroughly 50 | obs.on("InputVolumeMeters", (data) => { 51 | data.inputs.forEach((input: any) => { 52 | // Only update if non-zero audio levels 53 | if (input.inputLevelsMul.length == 0 || input.inputLevelsMul[0][0] == 0 || !settings.meterScaling || settings.meterScaling == 0) return; 54 | // I think [0] is left channel and [1] is right channel. 55 | // console.log(input.inputLevelsMul[0][1]); 56 | inputs[input.inputName].meter = input.inputLevelsMul[0][1] * settings.meterScaling; 57 | }); 58 | }); 59 | 60 | obs.on("CurrentProgramSceneChanged", (data) => { 61 | Object.values(scenes).forEach((button) => { 62 | button.active = data.sceneName === button.id; 63 | }); 64 | }); 65 | 66 | obs.on("ExitStarted", () => { 67 | disconnect(); 68 | init(); 69 | }) 70 | }; 71 | 72 | const mapSources = async () => { 73 | const data = await obs.call("GetInputList"); 74 | 75 | // TODO: Would prefer this not be "any" but the actual type is "JsonObject" which sucks to use 76 | data.inputs?.forEach(async (input: any) => { 77 | try { 78 | const [volume, muted] = await Promise.all([ 79 | obs 80 | .call("GetInputVolume", { 81 | inputName: input.inputName, 82 | }) 83 | .then((res) => res.inputVolumeMul), 84 | obs 85 | .call("GetInputMute", { 86 | inputName: input.inputName, 87 | }) 88 | .then((res) => res.inputMuted), 89 | ]); 90 | 91 | const assignment = new Assignment(input.inputName, { 92 | name: input.inputName, 93 | muted, 94 | volume, 95 | }); 96 | 97 | assignment.on("volumeChanged", (level: number) => { 98 | assignment.volume = level; 99 | obs.call("SetInputVolume", { 100 | inputName: input.inputName, 101 | inputVolumeMul: level, 102 | }); 103 | }); 104 | 105 | assignment.on("mutePressed", () => { 106 | obs.call("SetInputMute", { 107 | inputName: input.inputName, 108 | inputMuted: !assignment.muted, 109 | }); 110 | }); 111 | 112 | inputs[input.inputName] = assignment; 113 | } 114 | catch (e: any) { 115 | if (e instanceof OBSWebSocketError) { 116 | if (e.code == 604) { 117 | // https://github.com/obsproject/obs-websocket/blob/master/docs/generated/protocol.md#requeststatusinvalidresourcestate 118 | // Usual cause is that input does not support audio 119 | } 120 | else { 121 | console.log(e); 122 | console.log(input); 123 | } 124 | } 125 | } 126 | }); 127 | }; 128 | 129 | const mapScenes = async () => { 130 | const data = await obs.call("GetSceneList"); 131 | 132 | // TODO: Would prefer this not be "any" but the actual type is "JsonObject" which sucks to use 133 | data.scenes.forEach((scene: any) => { 134 | 135 | const button = new ButtonType(scene.sceneName, { 136 | name: `OBS: Switch to "${scene.sceneName}" scene`, 137 | active: scene.sceneName === data.currentProgramSceneName, 138 | }); 139 | 140 | button.on("pressed", () => { 141 | obs.call("SetCurrentProgramScene", { 142 | sceneName: scene.sceneName, 143 | }); 144 | 145 | button.active = true; 146 | }); 147 | 148 | scenes[scene.sceneName] = button; 149 | }); 150 | }; 151 | 152 | function disconnect() { 153 | console.log("Disconnecting"); 154 | obs.disconnect(); 155 | for (let k in inputs) { 156 | let s = inputs[k]; 157 | s.remove(); 158 | } 159 | 160 | for (let k in scenes) { 161 | let s = scenes[k]; 162 | s.remove(); 163 | } 164 | } 165 | 166 | 167 | const init = async () => { 168 | console.log("Initializing"); 169 | obs.disconnect(); 170 | inputs = {}; 171 | scenes = {}; 172 | 173 | try { 174 | $MM.setSettingsStatus("status", "Connecting..."); 175 | 176 | await connect(); 177 | registerListeners(); 178 | await Promise.all([mapSources(), mapScenes()]); 179 | 180 | $MM.setSettingsStatus("status", "Connected"); 181 | } catch (err: any) { 182 | console.warn("OBS error:", err); 183 | $MM.setSettingsStatus("status", err.description || err.message || err); 184 | } 185 | }; 186 | 187 | $MM.onSettingsButtonPress("reconnect", init); 188 | 189 | init(); 190 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": [ 3 | "src/**/*" 4 | ], 5 | "exclude": [ 6 | "node_modules" 7 | ], 8 | "compilerOptions": { 9 | /* Visit https://aka.ms/tsconfig.json to read more about this file */ 10 | /* Basic Options */ 11 | // "incremental": true, /* Enable incremental compilation */ 12 | "target": "ES2020" /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */, 13 | "module": "commonjs" /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */, 14 | "lib": [ 15 | "ES2020", 16 | "DOM" 17 | ] /* Specify library files to be included in the compilation. */, 18 | // "allowJs": true, /* Allow javascript files to be compiled. */ 19 | // "checkJs": true, /* Report errors in .js files. */ 20 | // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ 21 | // "declaration": true, /* Generates corresponding '.d.ts' file. */ 22 | // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ 23 | // "sourceMap": true, /* Generates corresponding '.map' file. */ 24 | // "outFile": "./", /* Concatenate and emit output to single file. */ 25 | "outDir": "./lib", /* Redirect output structure to the directory. */ 26 | // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ 27 | // "composite": true, /* Enable project compilation */ 28 | // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ 29 | // "removeComments": true, /* Do not emit comments to output. */ 30 | "noEmit": false /* Do not emit outputs. */, 31 | // "importHelpers": true, /* Import emit helpers from 'tslib'. */ 32 | // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ 33 | // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ 34 | /* Strict Type-Checking Options */ 35 | "strict": true /* Enable all strict type-checking options. */, 36 | // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ 37 | // "strictNullChecks": true, /* Enable strict null checks. */ 38 | // "strictFunctionTypes": true, /* Enable strict checking of function types. */ 39 | // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ 40 | // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ 41 | // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ 42 | // "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ 43 | /* Additional Checks */ 44 | // "noUnusedLocals": true, /* Report errors on unused locals. */ 45 | // "noUnusedParameters": true, /* Report errors on unused parameters. */ 46 | // "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ 47 | // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ 48 | // "noUncheckedIndexedAccess": true, /* Include 'undefined' in index signature results */ 49 | /* Module Resolution Options */ 50 | "moduleResolution": "node" /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */, 51 | // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ 52 | // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ 53 | // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ 54 | // "typeRoots": [], /* List of folders to include type definitions from. */ 55 | // "types": [], /* Type declaration files to be included in compilation. */ 56 | // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ 57 | "esModuleInterop": true /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */, 58 | // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ 59 | // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ 60 | /* Source Map Options */ 61 | // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ 62 | // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ 63 | // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ 64 | // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ 65 | /* Experimental Options */ 66 | // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ 67 | // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ 68 | /* Advanced Options */ 69 | "skipLibCheck": true /* Skip type checking of declaration files. */, 70 | "resolveJsonModule": true, 71 | "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ 72 | } 73 | } 74 | --------------------------------------------------------------------------------