Add support for basic led feedback

Current configuration is only tested with Novation Launchkey Mini mk1. Wider support will come through extended configuration files.
This commit is contained in:
Alice Gaudon 2020-07-19 10:08:45 +02:00
parent ddb654cc66
commit 29bd7822a3
14 changed files with 411 additions and 74 deletions

View File

@ -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',
}
};

View File

@ -1,3 +1,5 @@
export default abstract class Action {
public abstract async execute(velocity: number): Promise<void>;
import App from "./App";
export default interface Action {
performAction(app: App, velocity: number): Promise<void>;
}

View File

@ -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<void> {
await this.initObs();
await this.obsStateTracker.init(this);
await this.initMidi();
}
public async stop(): Promise<void> {
await this.jzz.stop();
await this.midiIn.close();
await this.obs.removeAllListeners();
await this.obs.disconnect();
}
public async reload(): Promise<void> {
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<void> {
@ -54,7 +70,7 @@ export default class App {
}
private async initMidi(): Promise<void> {
this.jzz = await jzz()
this.midiIn = jzz()
.openMidiIn(config.get<string>('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;
}
}
public getObsStateTracker(): ObsStateTracker {
return this.obsStateTracker;
}
private async enableInControl(): Promise<void> {
// 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<void> {
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<void> {
let action = jzz().openMidiOut(config.get<string>('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;
}
}

View File

@ -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<boolean> {
public async handleEvent(app: App, eventType: number, velocity: number): Promise<boolean> {
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;
}

View File

@ -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<boolean> {
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<boolean> {
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;
}

View File

@ -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<boolean> {
public async handleEvent(app: App, eventType: number, velocity: number): Promise<boolean> {
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) {

21
src/LedState.ts Normal file
View File

@ -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;
}
}

5
src/LedStateUpdater.ts Normal file
View File

@ -0,0 +1,5 @@
import App from "./App";
export default interface LedStateUpdater {
updateLedState(app: App, ledId: number): Promise<void>;
}

View File

@ -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<boolean>;
public abstract async init(app: App): Promise<void>;
protected async performAction(velocity: number): Promise<void> {
await this.action.execute(velocity);
public abstract async handleEvent(app: App, eventType: number, velocity: number): Promise<boolean>;
protected async executeAction(app: App, velocity: number): Promise<void> {
if (this.action) {
await this.action.performAction(app, velocity);
}
}
public async update(app: App): Promise<void> {
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,
}

View File

@ -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<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();
await configureApp(app);
await app.reload();
})().catch(console.error);
async function configureApp(app: App): Promise<void> {
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<string[]>('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]))
}
}

44
src/obs/ObsSceneButton.ts Normal file
View File

@ -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<void> {
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<void> {
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<void> {
if (!this.isCurrentScene(app)) {
app.led(0, this.ledId!, 1, 3);
await app.tick();
}
await app.getObs().send('SetCurrentScene', {'scene-name': this.sceneName});
}
}

21
src/obs/ObsSourceKnob.ts Normal file
View File

@ -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<void> {
}
public async performAction(app: App, velocity: number): Promise<void> {
await app.getObs().send('SetVolume', {source: this.sourceName, volume: toVolume(velocity)});
}
}

View File

@ -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<void> {
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<void> {
await app.getObs().send('ToggleMute', {source: this.sourceName});
}
public async updateLedState(app: App, ledId: number): Promise<void> {
const muted = app.getObsStateTracker().isSourceMuted(this.sourceName);
app.led(0, ledId, muted ? 0 : 3, muted ? 3 : 0);
}
}

View File

@ -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<void> {
// 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<void> {
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<void> {
this.sources = (<any>(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<void> {
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];
}
}