Skip to content

Commit

Permalink
Move MIDI classes to a separate package
Browse files Browse the repository at this point in the history
ref #336
  • Loading branch information
frostburn committed Nov 26, 2023
1 parent cf56de8 commit f442e1b
Show file tree
Hide file tree
Showing 5 changed files with 37 additions and 236 deletions.
12 changes: 11 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

3 changes: 2 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@
"vue": "^3.2.33",
"vue-router": "^4.1.5",
"webmidi": "^3.0.21",
"xen-dev-utils": "^0.1.4"
"xen-dev-utils": "^0.1.4",
"xen-midi": "^0.0.3"
},
"devDependencies": {
"@rushstack/eslint-patch": "^1.2.0",
Expand Down
46 changes: 17 additions & 29 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) => {
Expand All @@ -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);
Expand Down Expand Up @@ -812,9 +803,6 @@ watch(mainVolume, (newValue) => {
);
});
watch(midiOutput, sendPitchBendRange);
watch(midiOutputChannels, sendPitchBendRange);
function updateMidiInputChannels(newValue: Set<number>) {
midiInputChannels.clear();
newValue.forEach((channel) => midiInputChannels.add(channel));
Expand Down
7 changes: 4 additions & 3 deletions src/__tests__/midi.spec.ts
Original file line number Diff line number Diff line change
@@ -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", () => {
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
205 changes: 3 additions & 202 deletions src/midi.ts
Original file line number Diff line number Diff line change
@@ -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<number>;
log: (msg: string) => void;
private voices: Voice[];

constructor(
output: Output | null,
channels: Set<number>,
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<number>;
noteOffMap: Map<number, (rawRelease: number) => void>;
log: (msg: string) => void;

constructor(
callback: NoteOnCallback,
channels: Set<number>,
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.
Expand All @@ -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;
Expand Down

0 comments on commit f442e1b

Please sign in to comment.