diff --git a/package-lock.json b/package-lock.json index ace7a26c..ac7797c2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,8 +15,9 @@ "temperaments": "^0.4.5", "vue": "^3.2.33", "vue-router": "^4.1.5", - "webmidi": "^3.0.21", - "xen-dev-utils": "^0.1.4" + "webmidi": "^3.1.7", + "xen-dev-utils": "^0.1.4", + "xen-midi": "^0.1.0" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", @@ -347,9 +348,9 @@ "dev": true }, "node_modules/@types/webmidi": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/@types/webmidi/-/webmidi-2.0.6.tgz", - "integrity": "sha512-sfS0A5IryqmBrUpcGPipEPeFdpqmZzP6b6lZFxHKgz5n2Vhzh4yJ5P2TvoDUhDjqJyv0Y25ng0Qodgo2Vu08ug==", + "version": "2.0.10", + "resolved": "https://registry.npmjs.org/@types/webmidi/-/webmidi-2.0.10.tgz", + "integrity": "sha512-4RmTFMB6mN2h8XbJa1x3cOs9IOkXvFyHGcPUpUvWfmATuKg/J+dsFiMVgCE2EkpS+/8a8AP2tE3rQT1mLG7vEg==", "optional": true }, "node_modules/@types/yauzl": { @@ -1617,11 +1618,11 @@ } }, "node_modules/djipevents": { - "version": "2.0.6", - "resolved": "https://registry.npmjs.org/djipevents/-/djipevents-2.0.6.tgz", - "integrity": "sha512-2vS7/OVdqocBAIVPbxhk36swE4HnjUL1muoibrkoaCaEAEAF/ww8QbYm55PqlzMp51pvD01wHr/CkZmKZdrI/g==", + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/djipevents/-/djipevents-2.0.7.tgz", + "integrity": "sha512-KNFYaU85imxOCKOUsIR70Iz9E19r96/X7LSH+u0tSoZdpWcBdzoqtTsU+wuLhc6GMpSFob+KInkZAbfKi01Bjg==", "dependencies": { - "@babel/runtime": "^7.17.9" + "@babel/runtime": "^7.20.6" } }, "node_modules/doctrine": { @@ -2962,9 +2963,9 @@ "integrity": "sha512-nO6jcEfZWQXDhOiBtG2KvKyEptz7RVbpGP4vTD2hLBdmNQSsCiicO2Ioinv6UI4y9ukqnBpy+XZ9H6uLNgJTlw==" }, "node_modules/jazz-midi": { - "version": "1.7.6", - "resolved": "https://registry.npmjs.org/jazz-midi/-/jazz-midi-1.7.6.tgz", - "integrity": "sha512-vKCmBpotb1y+kEbRlu43COar2LzV46uOTTzyM/zTx5LR7WUTWle7FFC8rphqBVdryW66QtOyUA2N/DWZToBang==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/jazz-midi/-/jazz-midi-1.7.9.tgz", + "integrity": "sha512-c8c4BBgwxdsIr1iVm53nadCrtH7BUlnX3V95ciK/gbvXN/ndE5+POskBalXgqlc/r9p2XUbdLTrgrC6fou5p9w==", "optional": true, "engines": { "node": ">=10.0.0" @@ -3154,13 +3155,13 @@ } }, "node_modules/jzz": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/jzz/-/jzz-1.5.5.tgz", - "integrity": "sha512-+zaw42xTBFm5KlqykKpD0kFaTZbjYwIQjkfhXsLkPWsg4jDVmgme/WLgFqevHPHJEFlPlUdEYI3RrzN0PBAbCw==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/jzz/-/jzz-1.7.4.tgz", + "integrity": "sha512-Q66OuEauPKImGF/CibsWI/02IEHmoZpAbEq6OcPaz4sihJwmt0J+9gWywudcdD+19cfmekl2I9NsVqlde/Phww==", "optional": true, "dependencies": { - "@types/webmidi": "^2.0.6", - "jazz-midi": "^1.7.6" + "@types/webmidi": "^2.0.9", + "jazz-midi": "^1.7.9" } }, "node_modules/lazy-ass": { @@ -4973,17 +4974,17 @@ } }, "node_modules/webmidi": { - "version": "3.0.21", - "resolved": "https://registry.npmjs.org/webmidi/-/webmidi-3.0.21.tgz", - "integrity": "sha512-SStCLo099908jR0duYUTstVHNlKh9A/WvXIF5ED2ShGnE2MAy28UhrdzgjLsyDzOiBn8Od3ngT8g/MVa2TC5hg==", + "version": "3.1.7", + "resolved": "https://registry.npmjs.org/webmidi/-/webmidi-3.1.7.tgz", + "integrity": "sha512-Q3OGc/GMRF0ZkGFUnTiwe+DWXAIVGJa2jkcPIqwsUF555hlhAfxXFcD+7CvN+0FzNFH1aDkH2K2gQkEPKMrphw==", "dependencies": { - "djipevents": "^2.0.5" + "djipevents": "^2.0.7" }, "engines": { "node": ">=8.5" }, "optionalDependencies": { - "jzz": "^1.4.5" + "jzz": "^1.5.6" } }, "node_modules/whatwg-encoding": { @@ -5096,6 +5097,19 @@ "fraction.js": "^4.2.0" } }, + "node_modules/xen-midi": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/xen-midi/-/xen-midi-0.1.0.tgz", + "integrity": "sha512-P5XwXJNOL3/nO4BDmbv9oCoy6WBrutlQIMx6edHeZ+oefI74nxfHhYJgvKrREQZ5vI+R9B0LwF47dfRb8vuyZg==", + "dependencies": { + "webmidi": "^3.1.7", + "xen-dev-utils": "^0.1.4" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/frostburn" + } + }, "node_modules/xml-name-validator": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-4.0.0.tgz", diff --git a/package.json b/package.json index 1a103dd1..2fe08d81 100644 --- a/package.json +++ b/package.json @@ -20,8 +20,9 @@ "temperaments": "^0.4.5", "vue": "^3.2.33", "vue-router": "^4.1.5", - "webmidi": "^3.0.21", - "xen-dev-utils": "^0.1.4" + "webmidi": "^3.1.7", + "xen-dev-utils": "^0.1.4", + "xen-midi": "^0.1.0" }, "devDependencies": { "@rushstack/eslint-patch": "^1.2.0", diff --git a/src/App.vue b/src/App.vue index 4ba32701..3fc61718 100644 --- a/src/App.vue +++ b/src/App.vue @@ -14,13 +14,8 @@ import { } from "@/constants"; import { ScaleWorkshopOneData } from "@/scale-workshop-one"; import type { Input, Output } from "webmidi"; -import { - bendRangeInSemitones, - computeWhiteIndices, - MidiIn, - midiNoteInfo, - MidiOut, -} from "@/midi"; +import { computeWhiteIndices } from "@/midi"; +import { MidiIn, midiKeyInfo, MidiOut } from "xen-midi"; import { Keyboard, type CoordinateKeyboardEvent } from "@/keyboard"; import { decodeQuery, encodeQuery, type DecodedState } from "@/url-encode"; import { debounce } from "@/utils"; @@ -349,9 +344,9 @@ function getFrequency(index: number) { } } -const midiOut = computed(() => { - return new MidiOut(midiOutput.value as Output, midiOutputChannels.value); -}); +const midiOut = computed( + () => new MidiOut(midiOutput.value as Output, midiOutputChannels.value) +); function sendNoteOn(frequency: number, rawAttack: number) { const midiOff = midiOut.value.sendNoteOn(frequency, rawAttack); @@ -394,14 +389,17 @@ function sendNoteOn(frequency: number, rawAttack: number) { // Offset such that default base MIDI note doesn't move const WHITE_MODE_OFFSET = 69 - 40; -function midiNoteOn(index: number, rawAttack: number) { +function midiNoteOn(index: number, rawAttack?: number) { + if (rawAttack === undefined) { + rawAttack = 80; + } let frequency = frequencies.value[index]; if (!midiVelocityOn.value) { rawAttack = 80; } // Store state to ensure consistent note off. - const info = midiNoteInfo(index); + const info = midiKeyInfo(index); const whiteMode = midiWhiteMode.value; const indices = whiteIndices.value; @@ -453,11 +451,14 @@ function midiNoteOn(index: number, rawAttack: number) { if (isNaN(frequency)) { // eslint-disable-next-line @typescript-eslint/no-unused-vars - return (rawRelease: number) => {}; + return (rawRelease?: number) => {}; } const noteOff = sendNoteOn(frequency, rawAttack); - return (rawRelease: number) => { + return (rawRelease?: number) => { + if (rawRelease === undefined) { + rawRelease = 80; + } if (!midiVelocityOn.value) { rawRelease = 80; } @@ -485,11 +486,10 @@ const RESERVED_MESSAGES = ["noteon", "noteoff", "pitchbend"]; watch(midiInput, (newValue, oldValue) => { if (oldValue !== null) { - oldValue.removeListener(); + midiIn.unlisten(oldValue as Input); } if (newValue !== null) { - newValue.addListener("noteon", midiIn.noteOn.bind(midiIn)); - newValue.addListener("noteoff", midiIn.noteOff.bind(midiIn)); + midiIn.listen(newValue as Input); // Pass everything else through and distrubute among the channels newValue.addListener("midimessage", (event) => { @@ -515,15 +515,6 @@ watch(midiInput, (newValue, oldValue) => { } }); -function sendPitchBendRange() { - const output = midiOutput.value; - if (output !== null) { - midiOutputChannels.value.forEach((channel) => { - output.channels[channel].sendPitchBendRange(bendRangeInSemitones, 0); - }); - } -} - // === Virtual and typing keyboard === function keyboardNoteOn(index: number) { tuningTableKeyOn(index); @@ -812,9 +803,6 @@ watch(mainVolume, (newValue) => { ); }); -watch(midiOutput, sendPitchBendRange); -watch(midiOutputChannels, sendPitchBendRange); - function updateMidiInputChannels(newValue: Set) { midiInputChannels.clear(); newValue.forEach((channel) => midiInputChannels.add(channel)); diff --git a/src/__tests__/midi.spec.ts b/src/__tests__/midi.spec.ts index fd928b21..e7cf0ed5 100644 --- a/src/__tests__/midi.spec.ts +++ b/src/__tests__/midi.spec.ts @@ -1,6 +1,7 @@ import { describe, it, expect } from "vitest"; +import { midiKeyInfo } from "xen-midi"; -import { computeWhiteIndices, midiNoteInfo } from "../midi"; +import { computeWhiteIndices } from "../midi"; describe("White key to white color mapper", () => { it("reproduces a chromatic scale in the default (A minor) configuration", () => { @@ -20,7 +21,7 @@ describe("White key to white color mapper", () => { ]); for (let index = 69; index < 69 + 12; index++) { - const info = midiNoteInfo(index); + const info = midiKeyInfo(index); if (info.whiteNumber === undefined) { expect(map[info.sharpOf] + 1).toBe(index); } else { @@ -46,7 +47,7 @@ describe("White key to white color mapper", () => { ]); for (let index = 60; index < 60 + 12; index++) { - const info = midiNoteInfo(index); + const info = midiKeyInfo(index); if (info.whiteNumber === undefined) { expect(map[info.sharpOf] + 1).toBe(index); } else { diff --git a/src/midi.ts b/src/midi.ts index d7ca1962..f57f4f7c 100644 --- a/src/midi.ts +++ b/src/midi.ts @@ -1,204 +1,5 @@ -import type { NoteMessageEvent, Output } from "webmidi"; -import { ftom, mmod } from "xen-dev-utils"; - -export const bendRangeInSemitones = 2; - -// Large but finite number to signify voices that are off -const EXPIRED = 10000; - -// Abstraction for a pitch-bent midi channel. Polyphonic in pure octaves and 12edo in general. -type Voice = { - age: number; - channel: number; - centsOffset: number; -}; - -const EPSILON = 1e-6; - -// eslint-disable-next-line @typescript-eslint/no-unused-vars -function emptyNoteOff(rawRelease: number) {} - -export type NoteOff = typeof emptyNoteOff; - -export class MidiOut { - output: Output | null; - channels: Set; - log: (msg: string) => void; - private voices: Voice[]; - - constructor( - output: Output | null, - channels: Set, - log?: (msg: string) => void - ) { - this.output = output; - this.channels = channels; - if (log === undefined) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - this.log = (msg) => {}; - } else { - this.log = log; - } - - this.voices = []; - this.channels.forEach((channel) => { - this.voices.push({ - age: EXPIRED, - centsOffset: NaN, - channel, - }); - }); - } - - selectVoice(noteNumber: number, centsOffset: number) { - // Age signifies how many note ons have occured after voice intialization - this.voices.forEach((voice) => voice.age++); - - // Re-use a channel that already has the correct pitch bend - for (let i = 0; i < this.voices.length; ++i) { - if (Math.abs(this.voices[i].centsOffset - centsOffset) < EPSILON) { - this.log(`Re-using channel ${this.voices[i].channel}`); - this.voices[i].age = 0; - return this.voices[i]; - } - } - - // Nothing re-usable found. Use the oldest voice. - let oldestVoice = this.voices[0]; - this.voices.forEach((voice) => { - if (voice.age > oldestVoice.age) { - oldestVoice = voice; - } - }); - oldestVoice.age = 0; - oldestVoice.centsOffset = centsOffset; - return oldestVoice; - } - - sendNoteOn(frequency: number, rawAttack: number) { - if (this.output === null) { - return emptyNoteOff; - } - if (!this.channels.size) { - return emptyNoteOff; - } - const [noteNumber, centsOffset] = ftom(frequency); - if (noteNumber < 0 || noteNumber >= 128) { - return emptyNoteOff; - } - const voice = this.selectVoice(noteNumber, centsOffset); - this.log( - `Sending note on ${noteNumber} at velocity ${ - rawAttack / 127 - } on channel ${ - voice.channel - } with bend ${centsOffset} resulting from frequency ${frequency}` - ); - const bendRange = bendRangeInSemitones * 100; - this.output.channels[voice.channel].sendPitchBend(centsOffset / bendRange); - this.output.channels[voice.channel].sendNoteOn(noteNumber, { rawAttack }); - - const noteOff = (rawRelease: number) => { - this.log( - `Sending note off ${noteNumber} at velocity ${ - rawRelease / 127 - } on channel ${voice.channel}` - ); - voice.age = EXPIRED; - this.output!.channels[voice.channel].sendNoteOff(noteNumber, { - rawRelease, - }); - }; - return noteOff; - } -} - -export type NoteOnCallback = (index: number, rawAttack: number) => NoteOff; - -export class MidiIn { - callback: NoteOnCallback; - channels: Set; - noteOffMap: Map void>; - log: (msg: string) => void; - - constructor( - callback: NoteOnCallback, - channels: Set, - log?: (msg: string) => void - ) { - this.callback = callback; - this.channels = channels; - this.noteOffMap = new Map(); - if (log === undefined) { - // eslint-disable-next-line @typescript-eslint/no-unused-vars - this.log = (msg) => {}; - } else { - this.log = log; - } - } - - noteOn(event: NoteMessageEvent) { - if (!this.channels.has(event.message.channel)) { - return; - } - const noteNumber = event.note.number; - const attack = event.note.attack; - const rawAttack = event.note.rawAttack; - this.log(`Midi note on ${noteNumber} at velocity ${attack}`); - const noteOff = this.callback(noteNumber, rawAttack); - this.noteOffMap.set(noteNumber, noteOff); - } - - noteOff(event: NoteMessageEvent) { - if (!this.channels.has(event.message.channel)) { - return; - } - const noteNumber = event.note.number; - const release = event.note.release; - const rawRelease = event.note.rawRelease; - this.log(`Midi note off ${noteNumber} at velocity ${release}`); - const noteOff = this.noteOffMap.get(noteNumber); - if (noteOff !== undefined) { - this.noteOffMap.delete(noteNumber); - noteOff(rawRelease); - } - } - - deactivate() { - for (const [noteNumber, noteOff] of this.noteOffMap) { - this.noteOffMap.delete(noteNumber); - noteOff(80); - } - } -} - -export type MidiNoteInfo = { - whiteNumber?: number; - sharpOf?: number; - flatOf?: number; -}; - -const WHITES = [0, 2, 4, 5, 7, 9, 11]; - -export function midiNoteInfo(chromaticNumber: number) { - const octave = Math.floor(chromaticNumber / 12); - const index = chromaticNumber - 12 * octave; - if (WHITES.includes(index)) { - return { - whiteNumber: Math.floor((index + 1) / 2) + 7 * octave, - }; - } - if (index === 1 || index === 3) { - return { - sharpOf: (index - 1) / 2 + 7 * octave, - flatOf: (index + 1) / 2 + 7 * octave, - }; - } - return { - sharpOf: index / 2 + 7 * octave, - flatOf: (index + 2) / 2 + 7 * octave, - }; -} +import { mmod } from "xen-dev-utils"; +import { midiKeyInfo } from "xen-midi"; /** * Computes a mapping from white MIDI notes to white-colored scale degrees. @@ -209,7 +10,7 @@ export function midiNoteInfo(chromaticNumber: number) { * The user can violate this assumption, but that's just how SW is... */ export function computeWhiteIndices(baseMidiNote: number, colors: string[]) { - const info = midiNoteInfo(baseMidiNote); + const info = midiKeyInfo(baseMidiNote); colors = colors.map((c) => c.toLowerCase()); let index = baseMidiNote;