Julusian / node-midi

A node.js wrapper for RtMidi providing MIDI I/O
https://www.npmjs.com/package/@julusian/midi
MIT License
22 stars 7 forks source link

Disconnect/Reconnect events? #12

Open fedekrum opened 7 months ago

fedekrum commented 7 months ago

Hi, There is a post on the original node-midi repository. https://github.com/justinlatimer/node-midi/issues/226

Is there a way to do that with this version? Thanks

Julusian commented 7 months ago

Doing a bit of research, it seems like rtmidi doesn't provide disconnect/reconnect events so any implementation would need to utilise polling which could be achieved just as well in your code (ref https://github.com/micdah/RtMidi.Core/issues/18)

I am open to this library being extended with an implementation of that to avoid everyone having to reimplement it, but it needs someone to implement it

GottZ commented 4 months ago

Warning!

don't blindly use the following code, as it's not really production ready yet (it's a PoC) and needs cleanup:

I just made a base class for that.

const MidiDevice = class {
    deviceName = "";
    input = new midi.Input();
    output = new midi.Output();
    controls = new Map();
    ticks = new Map();
    contexts = new Map();
    tickContext = {};
    deltaTime = 0;
    #contextTemplate = null;

    constructor(deviceName, ctx) {
        this.deviceName = deviceName;

        this.#contextTemplate = function(ctx) {
            if (typeof ctx === "object") Object.assign(this, ctx);
        };
        this.#contextTemplate.prototype = {
            self: this,
            tick: this.tickContext,
        };
        if (typeof ctx === "object") Object.assign(this.#contextTemplate.prototype, ctx);

        this.input.on("message", (deltaTime, message) => {
            this.control(deltaTime, message);
        });
    }

    #checkPorts() {
        const { input, output } = this;
        const inC = input.getPortCount();
        const outC = output.getPortCount();

        const inConnected = input.isPortOpen();
        const outConnected = output.isPortOpen();

        let inPort = -1;
        for (let i = 0; i < inC; i++) {
            if (input.getPortName(i) === this.deviceName) {
                inPort = i;
                break;
            }
        }

        let outPort = -1;
        for (let i = 0; i < outC; i++) {
            if (output.getPortName(i) === this.deviceName) {
                outPort = i;
                break;
            }
        }

        if (inPort < 0 && inConnected) {
            console.log(`device ${this.deviceName} input port is open but not found. disconnecting`);
            input.closePort();
            this.tickContext.ev = "closeInput";
            if (this.ticks.has("closeInput")) this.ticks.get("closeInput").call(this.tickContext);
            delete this.tickContext.ev;
        }
        else if (inPort >= 0 && !inConnected) {
            console.log(`device ${this.deviceName} input port is closed but found. connecting`);
            input.openPort(inPort);
            this.tickContext.ev = "openInput";
            if (this.ticks.has("openInput")) this.ticks.get("openInput").call(this.tickContext);
            delete this.tickContext.ev;
        }

        if (outPort < 0 && outConnected) {
            console.log(`device ${this.deviceName} output port is open but not found. disconnecting`);
            output.closePort();
            this.tickContext.ev = "closeOutput";
            if (this.ticks.has("closeOutput")) this.ticks.get("closeOutput").call(this.tickContext);
            delete this.tickContext.ev;
        }
        else if (outPort >= 0 && !outConnected) {
            console.log(`device ${this.deviceName} output port is closed but found. connecting`);
            output.openPort(outPort);
            this.tickContext.ev = "openOutput";
            if (this.ticks.has("openOutput")) this.ticks.get("openOutput").call(this.tickContext);
            delete this.tickContext.ev;
        }
    }

    #debugVerbose = false;
    #debugEnabled = false;

    debug(verbose = false) {
        const self = this;
        this.#debugVerbose = verbose;
        this.#debugEnabled = true;
        this.addControl("debug", function ({status, control, value}) {
            console.log(`debug: ${self.deviceName} ${status} ${control} ${value} ${JSON.stringify(this, null, 2)} ${JSON.stringify(this.tick, null, 2)}`);
        });
    }

    #addThing(type, control, callback, ctx) {
        this[type].set(control, callback);
        if (!this.contexts.has(control)) this.contexts.set(control, new this.#contextTemplate(ctx));
        else if (typeof ctx === "object") Object.assign(this.contexts.get(control), ctx);
    }

    addControl(...args) {
        this.#addThing("controls", ...args);
        return this;
    }

    addTick(...args) {
        this.#addThing("ticks", ...args);
        return this;
    }

    #removeThing(type, control) {
        this[type].delete(control);
        if (!this.ticks.has(control)) this.contexts.delete(control);
    }

    removeControl(...args) {
        this.#removeThing("controls", ...args);
        return this;
    }

    removeTick(...args) {
        this.#removeThing("ticks", ...args);
        return this;
    }

    tick() {
        this.#checkPorts();

        this.ticks.forEach((callback, key) => {
            callback.call(this.contexts.get(key));
        });
    }

    control(deltaTime, message) {
        const [status, control, value] = message;
        const hasControl = this.controls.has(control);
        const hasDebug = this.controls.has("debug");
        if (!hasControl && !hasDebug) return;
        const identifier = hasControl ? control : "debug";
        const context = this.contexts.get(identifier);
        const callback = this.controls.get(identifier);
        const now = new Date();
        if (!("lastEvent" in context)) {
            context.lastEvent = now;
        }
        context.deltaTime = (now - context.lastEvent) / 1000;
        context.lastEvent = now;
        context.status = status;
        context.control = control;
        context.value = value;
        this.deltaTime = deltaTime;
        if (this.#debugEnabled && (this.#debugVerbose || !hasControl)) {
            this.controls.get("debug").call(context, {status, control, value});
        }
        if (hasControl) callback.call(context, {status, control, value});
    }
};

it can then be used like this:

const trinket = new class extends MidiDevice {
    constructor() {
        super("CircuitPython Audio");
        const self = this;

        /*// increase voicemeeter volume on control 1
        this.addControl(1, function ({value}) {
            if (value === 0) return;
            const gain = vm.api["Bus[0].Gain"] += 4;
            console.log(`setting voicemeeter gain to ${gain.toFixed(3)} dB`);
        });

        // decrease voicemeeter volume on control 2
        this.addControl(2, function ({value}) {
            if (value === 0) return;
            const gain = vm.api["Bus[0].Gain"] -= 4;
            console.log(`setting voicemeeter gain to ${gain.toFixed(3)} dB`);
        });*/

        // mute mic on control 3
        this.addTick("openOutput", function () {
            if (!ctx.vmdirty && this.ev !== "openOutput") return;
            const isMicMuted = vm.raw["Strip[0].Mute"];
            self.output.sendMessage([176, 0b0110001, isMicMuted ? 1 : 0]);
            self.output.sendMessage([176, 0b0110100, isMicMuted ? 0 : 1]);
            const isMainMuted = vm.raw["Bus[0].Mute"];
            self.output.sendMessage([176, 0b1000001, isMainMuted ? 1 : 0]);
            self.output.sendMessage([176, 0b1000010, isMainMuted ? 0 : 1]);
            const isHeadsetMuted = vm.raw["Bus[3].Mute"];
            self.output.sendMessage([176, 0b0001001, isHeadsetMuted ? 1 : 0]);
            self.output.sendMessage([176, 0b0001010, isHeadsetMuted ? 0 : 1]);

        });
        this.addControl(3, function ({value}) {
            if (value === 0 && this.deltaTime < 0.15) return;
            const isMuted = vm.raw["Strip[0].Mute"] ^= 1;
            console.log(`setting voicemeeter mic mute to ${isMuted ? "muted" : "unmuted"}`);
            self.output.sendMessage([176, 0b0110001, isMuted ? 1 : 0]);
            self.output.sendMessage([176, 0b0110100, isMuted ? 0 : 1]);
        });
    }
}

i have made it quite usable tbh..

// gain levels
for (let [i, bus] of sequence(4, 4)) {
    this.addControl(i, function ({value}) {
        // ToDo: debounce
        const gain = gains[value];
        console.log(`setting bus ${bus} gain for ${self.busNames.get(bus)} to ${gain.toFixed(3)} dB`);
        vm.raw[`Bus[${bus}].Gain`] = gain;
    });
}

all you then need to do, is to call tick() on it like this:

let tick = 0;
while(true) {
    tick++;
    try {
        const vmdirty = ctx.vmdirty = vm.isParametersDirty();
        if (vmdirty) vm.updateDeviceList();
        nanoKONTROL2.tick();
        trinket.tick();
        pr0Board.tick();
    } catch (e) {
        console.log("such e", e);
    }
    await sleep(100);
}

I'm currently re-designing the voicemeeter-remote module.. I'm re-imagining how to interact with some libraries, and currently work on solving such ideas:

const isMuted = vm.raw["Strip[0].Mute"] ^= 1;

while in future, it might just be:

const isMuted = vm.Strip[0].Mute.$ ^= 1;

when I'm done, I'll likely refine my node-midi-wrapper and post it as frontend for this module with such functions. rt-midi itself sure lacks features like that. you can't even properly pin-point devices if they have the same name.

emilis commented 1 month ago

I've been looking into this too.

It seems that on Linux+ALSA one could launch aseqdump -p 0:1 to listen to the events on the ALSA "Announce" port.

I think it would be best if it was possible to subscribe to the Announce port via node-midi instead of launching a separate process and parsing its output stream.

emilis commented 1 month ago

Here's a simple example of an EventEmitter that emits ALSA client connect / exit events:

import { EventEmitter }     from 'node:events';
import { spawn }            from 'node:child_process';

export const alsaClientEvents =   new EventEmitter;

const childProcess =        spawn( 'aseqdump', [ '-p', '0:1' ]);

childProcess.stdout.on( 'data', data => {

    const content =         data.toString();

    if( content.includes( 'Client exit' )){

        alsaClientEvents.emit( 'exit', content );

    } else if( content.includes( 'Client start' )){

        alsaClientEvents.emit( 'start', content );
    }
});