From 29bd7822a3f57a39800ac1346953a34014906ca5 Mon Sep 17 00:00:00 2001 From: Alice Gaudon Date: Sun, 19 Jul 2020 10:08:45 +0200 Subject: [PATCH] Add support for basic led feedback Current configuration is only tested with Novation Launchkey Mini mk1. Wider support will come through extended configuration files. --- config/default.ts | 7 +- src/Action.ts | 6 +- src/App.ts | 108 +++++++++++++++++++++++++-- src/ButtonAdvancedControl.ts | 12 +-- src/ButtonControl.ts | 16 ++-- src/KnobAdvancedControl.ts | 18 +++-- src/LedState.ts | 21 ++++++ src/LedStateUpdater.ts | 5 ++ src/MidiControl.ts | 27 +++++-- src/main.ts | 62 +++++++-------- src/obs/ObsSceneButton.ts | 44 +++++++++++ src/obs/ObsSourceKnob.ts | 21 ++++++ src/obs/ObsSourceToggleMuteButton.ts | 40 ++++++++++ src/obs/ObsStateTracker.ts | 98 ++++++++++++++++++++++++ 14 files changed, 411 insertions(+), 74 deletions(-) create mode 100644 src/LedState.ts create mode 100644 src/LedStateUpdater.ts create mode 100644 src/obs/ObsSceneButton.ts create mode 100644 src/obs/ObsSourceKnob.ts create mode 100644 src/obs/ObsSourceToggleMuteButton.ts create mode 100644 src/obs/ObsStateTracker.ts diff --git a/config/default.ts b/config/default.ts index d231234..5836c65 100644 --- a/config/default.ts +++ b/config/default.ts @@ -2,8 +2,13 @@ export default { obs: { address: '127.0.0.1:4444', password: 'secret', + audio_sources: [ + 'Desktop Audio', + 'Mic/Aux', + ], }, midi: { - controller: 'Launchkey Mini MIDI 1', + controller: 'Launchkey Mini MIDI 2', + output: 'Launchkey Mini MIDI 2', } }; \ No newline at end of file diff --git a/src/Action.ts b/src/Action.ts index 33aed47..d7ea498 100644 --- a/src/Action.ts +++ b/src/Action.ts @@ -1,3 +1,5 @@ -export default abstract class Action { - public abstract async execute(velocity: number): Promise; +import App from "./App"; + +export default interface Action { + performAction(app: App, velocity: number): Promise; } diff --git a/src/App.ts b/src/App.ts index 68d28e8..099792f 100644 --- a/src/App.ts +++ b/src/App.ts @@ -3,11 +3,15 @@ import console from "console"; import MidiControl from "./MidiControl"; import jzz from "jzz"; import ObsWebSocket from "obs-websocket-js"; +import LedState from "./LedState"; +import ObsStateTracker from "./obs/ObsStateTracker"; export default class App { - private readonly obs: ObsWebSocket = new ObsWebSocket(); - private readonly controls: MidiControl[] = []; - private jzz: any; + private obs: ObsWebSocket = new ObsWebSocket(); + private obsStateTracker: ObsStateTracker = new ObsStateTracker(this.obs); + private controls: MidiControl[] = []; + private midiIn: any; + private pendingStates: LedState[] = []; public constructor() { @@ -19,11 +23,23 @@ export default class App { public async start(): Promise { await this.initObs(); + await this.obsStateTracker.init(this); await this.initMidi(); } public async stop(): Promise { - await this.jzz.stop(); + await this.midiIn.close(); + await this.obs.removeAllListeners(); + await this.obs.disconnect(); + } + + public async reload(): Promise { + await this.obs.removeAllListeners(); + await this.midiIn.close(); + + this.obsStateTracker = new ObsStateTracker(this.obs); + await this.obsStateTracker.init(this); + await this.initMidi(); } private async initObs(): Promise { @@ -54,7 +70,7 @@ export default class App { } private async initMidi(): Promise { - this.jzz = await jzz() + this.midiIn = jzz() .openMidiIn(config.get('midi.controller')) .or('Cannot open MIDI In port!') .and(function (this: any) { @@ -67,6 +83,13 @@ export default class App { console.error(e); } }); + await this.midiIn; + + await this.enableInControl(); + + for (const control of this.controls) { + await control.init(this); + } } private async handleMidiMessage(msg: any) { @@ -76,7 +99,7 @@ export default class App { console.log('Midi:', eventType, id, velocity); for (const control of this.controls) { - if (control.id === id && await control.handleEvent(eventType, velocity)) { + if (control.id === id && await control.handleEvent(this, eventType, velocity)) { return; } } @@ -85,4 +108,75 @@ export default class App { public getObs(): ObsWebSocket { return this.obs; } -} \ No newline at end of file + + public getObsStateTracker(): ObsStateTracker { + return this.obsStateTracker; + } + + private async enableInControl(): Promise { + // Enable "in control" + this.led(0, 10, 0, 1); + this.led(0, 12, 0, 1); + + await this.tick(); + + // Led rainbow + for (let n = 96; n < 105; n++) { + this.led(0, n, n % 4, (n + 2) % 4, 2000); + this.led(0, n + 16, n % 4, (n + 2) % 4, 2000); + await this.tick(); + } + + await new Promise(resolve => { + setTimeout(resolve, 2000); + }); + + // Init led controls + await this.updateControls(); + } + + public async updateControls(): Promise { + for (const control of this.controls) { + await control.update(this); + } + await this.tick(); + } + + public led( + channel: number, + note: number, + green: number, + red: number, + duration: number = 0 + ): void { + this.pendingStates.push(new LedState( + channel, + note, + green, + red, + duration + )); + } + + public async tick(): Promise { + let action = jzz().openMidiOut(config.get('midi.output')) + .or('Cannot open MIDI Out port!'); + + for (const state of this.pendingStates) { + let color = (state.green << 4) | state.red; + + // console.log('Out', state.channel, state.note, color, state.duration); + // console.log('>', state.green, state.red); + + if (state.duration > 0) { + action.note(state.channel, state.note, color, state.duration); + } else { + action.noteOn(state.channel, state.note, color); + } + } + this.pendingStates = []; + + await action; + } +} + diff --git a/src/ButtonAdvancedControl.ts b/src/ButtonAdvancedControl.ts index 4c0c8f1..ca5db02 100644 --- a/src/ButtonAdvancedControl.ts +++ b/src/ButtonAdvancedControl.ts @@ -1,18 +1,18 @@ import MidiControl from "./MidiControl"; -import Action from "./Action"; +import App from "./App"; -export default class ButtonAdvancedControl extends MidiControl { +export default abstract class ButtonAdvancedControl extends MidiControl { private readonly triggerOnUp: boolean; - public constructor(id: number, action: Action, triggerOnUp: boolean = false) { - super(id, action); + protected constructor(id: number, triggerOnUp: boolean = false) { + super(id); this.triggerOnUp = triggerOnUp; } - public async handleEvent(eventType: number, velocity: number): Promise { + public async handleEvent(app: App, eventType: number, velocity: number): Promise { if (this.triggerOnUp && velocity === 0 || !this.triggerOnUp && velocity !== 0) { - await this.performAction(velocity === 0 ? 0 : 1); + await this.executeAction(app, velocity === 0 ? 0 : 1); return true; } diff --git a/src/ButtonControl.ts b/src/ButtonControl.ts index 2953433..97b1554 100644 --- a/src/ButtonControl.ts +++ b/src/ButtonControl.ts @@ -1,18 +1,18 @@ import MidiControl, {EventType} from "./MidiControl"; -import Action from "./Action"; +import App from "./App"; -export default class ButtonControl extends MidiControl { +export default abstract class ButtonControl extends MidiControl { private readonly triggerOnUp: boolean; - public constructor(id: number, action: Action, triggerOnUp: boolean = false) { - super(id, action); + protected constructor(id: number, triggerOnUp: boolean = false) { + super(id); this.triggerOnUp = triggerOnUp; } - public async handleEvent(eventType: number, velocity: number): Promise { - if (this.triggerOnUp && eventType === EventType.BUTTON_UP || - !this.triggerOnUp && eventType === EventType.BUTTON_DOWN) { - await this.performAction(velocity); + public async handleEvent(app: App, eventType: number, velocity: number): Promise { + if (this.triggerOnUp && [EventType.BUTTON_UP, EventType.IN_BUTTON_UP].indexOf(eventType) >= 0 || + !this.triggerOnUp && [EventType.BUTTON_DOWN, EventType.IN_BUTTON_DOWN].indexOf(eventType) >= 0) { + await this.executeAction(app, velocity); return true; } diff --git a/src/KnobAdvancedControl.ts b/src/KnobAdvancedControl.ts index 97e6bc7..2a91afb 100644 --- a/src/KnobAdvancedControl.ts +++ b/src/KnobAdvancedControl.ts @@ -1,14 +1,14 @@ import MidiControl, {EventType} from "./MidiControl"; -import Action from "./Action"; +import App from "./App"; -export default class KnobAdvancedControl extends MidiControl { - public constructor(id: number, action: Action) { - super(id, action); +export default abstract class KnobAdvancedControl extends MidiControl { + protected constructor(id: number) { + super(id); } - public async handleEvent(eventType: number, velocity: number): Promise { + public async handleEvent(app: App, eventType: number, velocity: number): Promise { if (eventType === EventType.ADVANCED_CONTROL) { - await this.performAction(velocity); + await this.executeAction(app, velocity); return true; } @@ -16,8 +16,12 @@ export default class KnobAdvancedControl extends MidiControl { } } +export function toRawVolume(velocity: number) { + return velocity / 127; +} + export function toVolume(velocity: number) { - return dbToLinear(linearToDef(velocity / 127)); + return dbToLinear(linearToDef(toRawVolume(velocity))); } function dbToLinear(x: number) { diff --git a/src/LedState.ts b/src/LedState.ts new file mode 100644 index 0000000..fbf7bc3 --- /dev/null +++ b/src/LedState.ts @@ -0,0 +1,21 @@ +export default class LedState { + public readonly channel: number; + public readonly note: number; + public readonly green: number; + public readonly red: number; + public readonly duration: number; + + constructor( + channel: number, + note: number, + green: number, + red: number, + duration: number = 0 + ) { + this.channel = channel; + this.note = note; + this.green = green; + this.red = red; + this.duration = duration; + } +} \ No newline at end of file diff --git a/src/LedStateUpdater.ts b/src/LedStateUpdater.ts new file mode 100644 index 0000000..3f58ca3 --- /dev/null +++ b/src/LedStateUpdater.ts @@ -0,0 +1,5 @@ +import App from "./App"; + +export default interface LedStateUpdater { + updateLedState(app: App, ledId: number): Promise; +} \ No newline at end of file diff --git a/src/MidiControl.ts b/src/MidiControl.ts index e2b956d..8b68192 100644 --- a/src/MidiControl.ts +++ b/src/MidiControl.ts @@ -1,18 +1,31 @@ import Action from "./Action"; +import App from "./App"; +import LedStateUpdater from "./LedStateUpdater"; export default abstract class MidiControl { public readonly id: number; - private readonly action: Action; + protected action?: Action; + protected ledId?: number; + protected ledStateUpdater?: LedStateUpdater; - protected constructor(id: number, action: Action) { + protected constructor(id: number) { this.id = id; - this.action = action; } - public abstract async handleEvent(eventType: number, velocity: number): Promise; + public abstract async init(app: App): Promise; - protected async performAction(velocity: number): Promise { - await this.action.execute(velocity); + public abstract async handleEvent(app: App, eventType: number, velocity: number): Promise; + + protected async executeAction(app: App, velocity: number): Promise { + if (this.action) { + await this.action.performAction(app, velocity); + } + } + + public async update(app: App): Promise { + if (typeof this.ledId === 'number' && this.ledStateUpdater) { + await this.ledStateUpdater.updateLedState(app, this.ledId); + } } } @@ -20,4 +33,6 @@ export enum EventType { BUTTON_DOWN = 153, BUTTON_UP = 137, ADVANCED_CONTROL = 176, + IN_BUTTON_DOWN = 144, + IN_BUTTON_UP = 128, } diff --git a/src/main.ts b/src/main.ts index 3b23f47..8c4d087 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,44 +1,32 @@ import * as console from "console"; import App from "./App"; -import ButtonControl from "./ButtonControl"; -import Action from "./Action"; -import KnobAdvancedControl, {toVolume} from "./KnobAdvancedControl"; +import ObsSceneButton from "./obs/ObsSceneButton"; +import ObsSourceToggleMuteButton from "./obs/ObsSourceToggleMuteButton"; +import ObsSourceKnob from "./obs/ObsSourceKnob"; +import config from "config"; (async () => { const app = new App(); - - app.registerControl(new ButtonControl(50, new class extends Action { - async execute(velocity: number): Promise { - await app.getObs().send('SetCurrentScene', {'scene-name': 'Scene 2'}); - } - })); - app.registerControl(new ButtonControl(51, new class extends Action { - async execute(velocity: number): Promise { - await app.getObs().send('SetCurrentScene', {'scene-name': 'BareMainScreen'}); - } - })); - - app.registerControl(new ButtonControl(40, new class extends Action { - async execute(velocity: number): Promise { - await app.getObs().send('ToggleMute', {source: 'Desktop Audio'}); - } - })); - - app.registerControl(new ButtonControl(41, new class extends Action { - async execute(velocity: number): Promise { - await app.getObs().send('ToggleMute', {source: 'Mic/Aux'}); - } - })); - app.registerControl(new KnobAdvancedControl(21, new class extends Action { - async execute(velocity: number): Promise { - 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 { - await app.getObs().send('SetVolume', {source: 'Mic/Aux', volume: toVolume(velocity)}); - } - })); - await app.start(); + + await configureApp(app); + + await app.reload(); })().catch(console.error); + +async function configureApp(app: App): Promise { + let obsStateTracker = app.getObsStateTracker(); + + // Scenes + const scenes = obsStateTracker.getSceneList(); + for (let i = 0; i < scenes.length && i < 8; i++) { + app.registerControl(new ObsSceneButton(112 + i, scenes[i])); + } + + // Sources + const sources = config.get('obs.audio_sources'); + for (let i = 0; i < sources.length && i < 8; i++) { + app.registerControl(new ObsSourceToggleMuteButton(96 + i, sources[i])); + app.registerControl(new ObsSourceKnob(21 + i, sources[i])) + } +} \ No newline at end of file diff --git a/src/obs/ObsSceneButton.ts b/src/obs/ObsSceneButton.ts new file mode 100644 index 0000000..b01464d --- /dev/null +++ b/src/obs/ObsSceneButton.ts @@ -0,0 +1,44 @@ +import ButtonControl from "../ButtonControl"; +import App from "../App"; +import LedStateUpdater from "../LedStateUpdater"; +import Action from "../Action"; + +export default class ObsSceneButton extends ButtonControl implements Action, LedStateUpdater { + private readonly sceneName: string; + + public constructor(id: number, sceneName: string, triggerOnUp: boolean = false) { + super(id, triggerOnUp); + this.sceneName = sceneName; + this.action = this; + this.ledId = id; + this.ledStateUpdater = this; + } + + public async init(app: App): Promise { + app.getObs().on('SwitchScenes', async data => { + try { + await this.update(app); + await app.tick(); + } catch (e) { + console.error(e); + } + }); + } + + public async updateLedState(app: App, ledId: number): Promise { + let isCurrentScene = this.isCurrentScene(app); + app.led(0, ledId, isCurrentScene ? 3 : 0, isCurrentScene ? 0 : 3); + } + + private isCurrentScene(app: App) { + return app.getObsStateTracker().getCurrentScene() === this.sceneName; + } + + public async performAction(app: App, velocity: number): Promise { + if (!this.isCurrentScene(app)) { + app.led(0, this.ledId!, 1, 3); + await app.tick(); + } + await app.getObs().send('SetCurrentScene', {'scene-name': this.sceneName}); + } +} \ No newline at end of file diff --git a/src/obs/ObsSourceKnob.ts b/src/obs/ObsSourceKnob.ts new file mode 100644 index 0000000..41aa720 --- /dev/null +++ b/src/obs/ObsSourceKnob.ts @@ -0,0 +1,21 @@ +import KnobAdvancedControl, {toVolume} from "../KnobAdvancedControl"; +import Action from "../Action"; +import App from "../App"; + +export default class ObsSourceKnob extends KnobAdvancedControl implements Action { + private readonly sourceName: string; + + constructor(id: number, sourceName: string) { + super(id); + this.sourceName = sourceName; + this.action = this; + } + + public async init(app: App): Promise { + } + + public async performAction(app: App, velocity: number): Promise { + await app.getObs().send('SetVolume', {source: this.sourceName, volume: toVolume(velocity)}); + } + +} \ No newline at end of file diff --git a/src/obs/ObsSourceToggleMuteButton.ts b/src/obs/ObsSourceToggleMuteButton.ts new file mode 100644 index 0000000..4c255d0 --- /dev/null +++ b/src/obs/ObsSourceToggleMuteButton.ts @@ -0,0 +1,40 @@ +import ButtonControl from "../ButtonControl"; +import Action from "../Action"; +import LedStateUpdater from "../LedStateUpdater"; +import App from "../App"; + +export default class ObsSourceToggleMuteButton extends ButtonControl implements Action, LedStateUpdater { + private readonly sourceName: string; + + + public constructor(id: number, sourceName: string, triggerOnUp: boolean = false) { + super(id, triggerOnUp); + this.sourceName = sourceName; + this.action = this; + this.ledId = id; + this.ledStateUpdater = this; + } + + public async init(app: App): Promise { + app.getObs().on('SourceMuteStateChanged', async data => { + if (data.sourceName === this.sourceName) { + try { + await this.update(app); + await app.tick(); + } catch (e) { + console.error(e); + } + } + }); + } + + public async performAction(app: App, velocity: number): Promise { + await app.getObs().send('ToggleMute', {source: this.sourceName}); + } + + public async updateLedState(app: App, ledId: number): Promise { + const muted = app.getObsStateTracker().isSourceMuted(this.sourceName); + app.led(0, ledId, muted ? 0 : 3, muted ? 3 : 0); + } + +} \ No newline at end of file diff --git a/src/obs/ObsStateTracker.ts b/src/obs/ObsStateTracker.ts new file mode 100644 index 0000000..693beb5 --- /dev/null +++ b/src/obs/ObsStateTracker.ts @@ -0,0 +1,98 @@ +import ObsWebSocket from "obs-websocket-js"; +import App from "../App"; + +export default class ObsStateTracker { + private readonly obs: ObsWebSocket; + private currentScene: string = ''; + private scenes: ObsWebSocket.Scene[] = []; + private sources: { name: string, type: string, typeId: string }[] = []; + private sourceMuteStates: { [p: string]: boolean } = {}; + + public constructor(obs: ObsWebSocket) { + this.obs = obs; + } + + public async init(app: App): Promise { + // Current scene + this.obs.on('SwitchScenes', async data => { + try { + await this.setScene(data['scene-name']); + } catch (e) { + console.error(e); + } + }); + this.currentScene = (await this.obs.send('GetCurrentScene')).name; + + // Scene list + this.obs.on('ScenesChanged', async data => { + console.log('Scenes changed.'); + try { + await this.updateScenes(); + } catch (e) { + console.error(e); + } + }); + await this.updateScenes(); + + // Source list + const sourceUpdateListener = async () => { + try { + await this.updateSources(); + } catch (e) { + console.error(e); + } + }; + this.obs.on('SourceCreated', sourceUpdateListener); + this.obs.on('SourceDestroyed', sourceUpdateListener); + this.obs.on('SourceRenamed', sourceUpdateListener); + + // Source mute states + this.obs.on('SourceMuteStateChanged', data => { + this.sourceMuteStates[data.sourceName] = data.muted; + }); + + await this.updateSources(); + } + + private async updateScenes(): Promise { + let data = await this.obs.send('GetSceneList'); + this.scenes = data.scenes; + await this.setScene(data["current-scene"]); + console.log('Scene switched to', this.currentScene); + } + + private async updateSources(): Promise { + this.sources = ((await this.obs.send('GetSourcesList')).sources); + + // Source mute states + this.sourceMuteStates = {}; + console.log('Loading source mute states...'); + for (const source of this.sources) { + console.log('>', source.name); + this.sourceMuteStates[source.name] = (await this.obs.send('GetMute', {source: source.name})).muted; + } + } + + public getCurrentScene(): string { + return this.currentScene; + } + + private async setScene(scene: string): Promise { + if (this.currentScene !== scene) { + this.currentScene = scene; + console.log('Scene switched to', this.currentScene); + } + } + + public getSceneList(): string[] { + return this.scenes.map(s => s.name); + } + + public getSourcesList(): string[] { + return this.sources.map(s => s.name); + } + + public isSourceMuted(sceneName: string): boolean { + return this.sourceMuteStates[sceneName]; + } +} \ No newline at end of file