@ -0,0 +1,5 @@ | |||
.idea | |||
dist | |||
node_modules | |||
yarn.lock | |||
yarn-error.log |
@ -0,0 +1,3 @@ | |||
# OBS midi | |||
Control OBS studio (and more!) with any midi device. |
@ -0,0 +1,9 @@ | |||
export default { | |||
obs: { | |||
address: '127.0.0.1:4444', | |||
password: 'secret', | |||
}, | |||
midi: { | |||
controller: 'Launchkey Mini MIDI 1', | |||
} | |||
}; |
@ -0,0 +1,22 @@ | |||
{ | |||
"name": "obs-midi", | |||
"version": "0.1.0", | |||
"description": "Control OBS with any midi controller.", | |||
"main": "dist/main.js", | |||
"author": "Alice Gaudon <alice@gaudon.pro>", | |||
"license": "MIT", | |||
"scripts": { | |||
"dev": "yarn tsc && node ." | |||
}, | |||
"devDependencies": { | |||
"@types/node": "^14.0.23", | |||
"typescript": "^3.9.7" | |||
}, | |||
"dependencies": { | |||
"@types/config": "^0.0.36", | |||
"config": "^3.3.1", | |||
"jzz": "^1.0.8", | |||
"obs-websocket-js": "^4.0.1", | |||
"ts-node": "^8.10.2" | |||
} | |||
} |
@ -0,0 +1,3 @@ | |||
export default abstract class Action { | |||
public abstract async execute(velocity: number): Promise<void>; | |||
} |
@ -0,0 +1,88 @@ | |||
import config from "config"; | |||
import console from "console"; | |||
import MidiControl from "./MidiControl"; | |||
import jzz from "jzz"; | |||
import ObsWebSocket from "obs-websocket-js"; | |||
export default class App { | |||
private readonly obs: ObsWebSocket = new ObsWebSocket(); | |||
private readonly controls: MidiControl[] = []; | |||
private jzz: any; | |||
public constructor() { | |||
} | |||
public registerControl(control: MidiControl) { | |||
this.controls.push(control); | |||
} | |||
public async start(): Promise<void> { | |||
await this.initObs(); | |||
await this.initMidi(); | |||
} | |||
public async stop(): Promise<void> { | |||
await this.jzz.stop(); | |||
} | |||
private async initObs(): Promise<void> { | |||
const connectionRetryListener = async () => { | |||
try { | |||
console.error('Connection closed or authentication failure. Retrying in 2s...'); | |||
await new Promise(resolve => { | |||
setTimeout(() => { | |||
resolve(); | |||
}, 2000); | |||
}); | |||
await this.connectObs(); | |||
} catch (e) { | |||
console.error(e); | |||
} | |||
}; | |||
this.obs.on('ConnectionClosed', connectionRetryListener); | |||
this.obs.on('AuthenticationFailure', connectionRetryListener); | |||
await this.connectObs(); | |||
} | |||
private async connectObs(): Promise<void> { | |||
await this.obs.connect({ | |||
address: config.get<string>('obs.address'), | |||
password: config.get<string>('obs.password'), | |||
}); | |||
} | |||
private async initMidi(): Promise<void> { | |||
this.jzz = await jzz() | |||
.openMidiIn(config.get<string>('midi.controller')) | |||
.or('Cannot open MIDI In port!') | |||
.and(function (this: any) { | |||
console.log('MIDI-In:', this.name()); | |||
}) | |||
.connect(async (msg: any) => { | |||
try { | |||
await this.handleMidiMessage(msg); | |||
} catch (e) { | |||
console.error(e); | |||
} | |||
}); | |||
} | |||
private async handleMidiMessage(msg: any) { | |||
const eventType = msg['0']; | |||
const id = msg['1']; | |||
const velocity = msg['2']; | |||
console.log('Midi:', eventType, id, velocity); | |||
for (const control of this.controls) { | |||
if (control.id === id && await control.handleEvent(eventType, velocity)) { | |||
return; | |||
} | |||
} | |||
} | |||
public getObs(): ObsWebSocket { | |||
return this.obs; | |||
} | |||
} |
@ -0,0 +1,22 @@ | |||
import MidiControl from "./MidiControl"; | |||
import Action from "./Action"; | |||
export default class ButtonAdvancedControl extends MidiControl { | |||
private readonly triggerOnUp: boolean; | |||
public constructor(id: number, action: Action, triggerOnUp: boolean = false) { | |||
super(id, action); | |||
this.triggerOnUp = triggerOnUp; | |||
} | |||
public async handleEvent(eventType: number, velocity: number): Promise<boolean> { | |||
if (this.triggerOnUp && velocity === 0 || | |||
!this.triggerOnUp && velocity !== 0) { | |||
await this.performAction(velocity === 0 ? 0 : 1); | |||
return true; | |||
} | |||
return false; | |||
} | |||
} |
@ -0,0 +1,22 @@ | |||
import MidiControl, {EventType} from "./MidiControl"; | |||
import Action from "./Action"; | |||
export default class ButtonControl extends MidiControl { | |||
private readonly triggerOnUp: boolean; | |||
public constructor(id: number, action: Action, triggerOnUp: boolean = false) { | |||
super(id, action); | |||
this.triggerOnUp = triggerOnUp; | |||
} | |||
public async handleEvent(eventType: number, velocity: number): Promise<boolean> { | |||
if (this.triggerOnUp && eventType === EventType.BUTTON_UP || | |||
!this.triggerOnUp && eventType === EventType.BUTTON_DOWN) { | |||
await this.performAction(velocity); | |||
return true; | |||
} | |||
return false; | |||
} | |||
} |
@ -0,0 +1,53 @@ | |||
import MidiControl, {EventType} from "./MidiControl"; | |||
import Action from "./Action"; | |||
export default class KnobAdvancedControl extends MidiControl { | |||
public constructor(id: number, action: Action) { | |||
super(id, action); | |||
} | |||
public async handleEvent(eventType: number, velocity: number): Promise<boolean> { | |||
if (eventType === EventType.ADVANCED_CONTROL) { | |||
await this.performAction(velocity); | |||
return true; | |||
} | |||
return false; | |||
} | |||
} | |||
export function toVolume(velocity: number) { | |||
return dbToLinear(linearToDef(velocity / 127)); | |||
} | |||
function dbToLinear(x: number) { | |||
return Math.pow(10, x / 20); | |||
} | |||
/** | |||
* @author Arkhist (thanks!) | |||
*/ | |||
function linearToDef(y: number) { | |||
if (y >= 1) return 0; | |||
if (y >= 0.75) | |||
return reverseDef(y, 9, 9, 0.25, 0.75); | |||
else if (y >= 0.5) | |||
return reverseDef(y, 20, 11, 0.25, 0.5); | |||
else if (y >= 0.3) | |||
return reverseDef(y, 30, 10, 0.2, 0.3); | |||
else if (y >= 0.15) | |||
return reverseDef(y, 40, 10, 0.15, 0.15); | |||
else if (y >= 0.075) | |||
return reverseDef(y, 50, 10, 0.075, 0.075); | |||
else if (y >= 0.025) | |||
return reverseDef(y, 60, 10, 0.05, 0.025); | |||
else if (y > 0) | |||
return reverseDef(y, 150, 90, 0.025, 0); | |||
else | |||
return -15000; | |||
} | |||
function reverseDef(y: number, a1: number, d: number, m: number, a2: number) { | |||
return ((y - a2) / m) * d - a1; | |||
} |
@ -0,0 +1,23 @@ | |||
import Action from "./Action"; | |||
export default abstract class MidiControl { | |||
public readonly id: number; | |||
private readonly action: Action; | |||
protected constructor(id: number, action: Action) { | |||
this.id = id; | |||
this.action = action; | |||
} | |||
public abstract async handleEvent(eventType: number, velocity: number): Promise<boolean>; | |||
protected async performAction(velocity: number): Promise<void> { | |||
await this.action.execute(velocity); | |||
} | |||
} | |||
export enum EventType { | |||
BUTTON_DOWN = 153, | |||
BUTTON_UP = 137, | |||
ADVANCED_CONTROL = 176, | |||
} |
@ -0,0 +1,44 @@ | |||
import * as console from "console"; | |||
import App from "./App"; | |||
import ButtonControl from "./ButtonControl"; | |||
import Action from "./Action"; | |||
import KnobAdvancedControl, {toVolume} from "./KnobAdvancedControl"; | |||
(async () => { | |||
const app = new App(); | |||
app.registerControl(new ButtonControl(50, new class extends Action { | |||
async execute(velocity: number): Promise<void> { | |||
await app.getObs().send('SetCurrentScene', {'scene-name': 'Scene 2'}); | |||
} | |||
})); | |||
app.registerControl(new ButtonControl(51, new class extends Action { | |||
async execute(velocity: number): Promise<void> { | |||
await app.getObs().send('SetCurrentScene', {'scene-name': 'BareMainScreen'}); | |||
} | |||
})); | |||
app.registerControl(new ButtonControl(40, new class extends Action { | |||
async execute(velocity: number): Promise<void> { | |||
await app.getObs().send('ToggleMute', {source: 'Desktop Audio'}); | |||
} | |||
})); | |||
app.registerControl(new ButtonControl(41, new class extends Action { | |||
async execute(velocity: number): Promise<void> { | |||
await app.getObs().send('ToggleMute', {source: 'Mic/Aux'}); | |||
} | |||
})); | |||
app.registerControl(new KnobAdvancedControl(21, new class extends Action { | |||
async execute(velocity: number): Promise<void> { | |||
await app.getObs().send('SetVolume', {source: 'Desktop Audio', volume: toVolume(velocity)}); | |||
} | |||
})); | |||
app.registerControl(new KnobAdvancedControl(22, new class extends Action { | |||
async execute(velocity: number): Promise<void> { | |||
await app.getObs().send('SetVolume', {source: 'Mic/Aux', volume: toVolume(velocity)}); | |||
} | |||
})); | |||
await app.start(); | |||
})().catch(console.error); |
@ -0,0 +1,20 @@ | |||
{ | |||
"compilerOptions": { | |||
"module": "CommonJS", | |||
"esModuleInterop": true, | |||
"outDir": "dist", | |||
"target": "ES6", | |||
"strict": true, | |||
"lib": [ | |||
"es2020", | |||
"DOM" | |||
], | |||
"typeRoots": [ | |||
"./node_modules/@types", | |||
"./src/types" | |||
] | |||
}, | |||
"include": [ | |||
"src/**/*" | |||
] | |||
} |