Skip to content

Commit

Permalink
Merge pull request #390 from xenharmonic-devs/midi-mapping-visual
Browse files Browse the repository at this point in the history
Visualize MIDI mapping and active MIDI keys
  • Loading branch information
frostburn authored Nov 26, 2023
2 parents 978084b + 1fab102 commit 7728aa0
Show file tree
Hide file tree
Showing 5 changed files with 247 additions and 5 deletions.
4 changes: 1 addition & 3 deletions src/App.vue
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
NEWLINE_TEST,
NUMBER_OF_NOTES,
UNIX_NEWLINE,
WHITE_MODE_OFFSET,
} from "@/constants";
import { ScaleWorkshopOneData } from "@/scale-workshop-one";
import type { Input, Output } from "webmidi";
Expand Down Expand Up @@ -386,9 +387,6 @@ function sendNoteOn(frequency: number, rawAttack: number) {
return off;
}
// Offset such that default base MIDI note doesn't move
const WHITE_MODE_OFFSET = 69 - 40;
function midiNoteOn(index: number, rawAttack?: number) {
if (rawAttack === undefined) {
rawAttack = 80;
Expand Down
184 changes: 184 additions & 0 deletions src/components/MidiPiano.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,184 @@
<script setup lang="ts">
import { WHITE_MODE_OFFSET } from "@/constants";
import { computeWhiteIndices } from "@/midi";
import { computed } from "vue";
import { midiKeyInfo } from "xen-midi";
const props = defineProps<{
baseMidiNote: number;
midiWhiteMode: "off" | "simple" | "blackAverage" | "keyColors";
keyColors: string[];
activeKeys: Set<number>;
}>();
// The svg container preserves aspect ratio so at least an octave around the base midi note is shown.
// View box is centered around 0,0 and has a logical width of 100 units (padding is added to contain strokes).
const KEY_SCALE = 10;
const BLACK_WIDTH = 0.6 * KEY_SCALE;
// Close enough.
const center = computed(() => {
const info = midiKeyInfo(props.baseMidiNote);
if (info.whiteNumber) {
return info.whiteNumber * KEY_SCALE;
}
return (info.sharpOf! + 0.5) * KEY_SCALE;
});
const whiteIndices = computed(() =>
computeWhiteIndices(props.baseMidiNote, props.keyColors)
);
function keyLabel(chromaticNumber: number) {
const info = midiKeyInfo(chromaticNumber);
if (props.midiWhiteMode === "off") {
return [(chromaticNumber - props.baseMidiNote).toString()];
} else if (props.midiWhiteMode === "simple") {
if (info.whiteNumber !== undefined) {
return [
(info.whiteNumber + WHITE_MODE_OFFSET - props.baseMidiNote).toString(),
];
}
} else if (props.midiWhiteMode === "blackAverage") {
const offset = WHITE_MODE_OFFSET - props.baseMidiNote;
if (info.whiteNumber === undefined) {
return [
(info.flatOf + offset).toString(),
"\u2295",
(info.sharpOf + offset).toString(),
];
} else {
return [(info.whiteNumber + offset).toString()];
}
} else {
const indices = whiteIndices.value;
if (indices.length) {
if (info.whiteNumber === undefined) {
// Use a black key if available
const index = indices[info.sharpOf] + 1;
// Eliminate duplicates
if (index === indices[info.sharpOf + 1]) {
return [];
} else {
return [(index - props.baseMidiNote).toString()];
}
} else {
return [(indices[info.whiteNumber] - props.baseMidiNote).toString()];
}
}
}
return [];
}
const whiteKeys = computed(() => {
const result = [];
for (let i = 0; i < 128; ++i) {
const info = midiKeyInfo(i);
if (info.whiteNumber !== undefined) {
const x = info.whiteNumber;
result.push({
x: x * KEY_SCALE - center.value,
label: keyLabel(i),
index: i,
});
}
}
return result;
});
const blackKeys = computed(() => {
const result = [];
for (let i = 0; i < 128; ++i) {
const info = midiKeyInfo(i);
const label = keyLabel(i);
if (info.sharpOf !== undefined) {
const x =
info.sharpOf * KEY_SCALE + KEY_SCALE - 0.5 * BLACK_WIDTH - center.value;
const left = midiKeyInfo(i - 2);
const right = midiKeyInfo(i + 2);
if (left.whiteNumber !== undefined) {
result.push({ x: x - 0.2 * BLACK_WIDTH, label, index: i });
} else if (right.whiteNumber !== undefined) {
result.push({ x: x + 0.2 * BLACK_WIDTH, label, index: i });
} else {
result.push({ x, label, index: i });
}
}
}
return result;
});
</script>

<template>
<svg width="100%" height="100%" viewBox="-51 -51 102 102">
<g v-for="key of whiteKeys" :key="key.index">
<rect
:class="{ white: true, active: activeKeys.has(key.index) }"
:x="key.x"
y="-50"
height="100"
:width="KEY_SCALE"
stroke="gray"
stroke-width="0.1"
/>
<text
y="20"
:font-size="0.5 * KEY_SCALE"
text-anchor="middle"
fill="black"
>
<tspan
v-for="(line, i) of key.label"
:key="i"
:x="key.x + KEY_SCALE * 0.5"
:dy="0.5 * KEY_SCALE"
>
{{ line }}
</tspan>
</text>
</g>
<g v-for="key of blackKeys" :key="key.index">
<rect
:class="{ black: true, active: activeKeys.has(key.index) }"
:x="key.x"
y="-50"
height="60"
:width="BLACK_WIDTH"
stroke="gray"
stroke-width="0.1"
/>
<text
y="-5"
:font-size="0.6 * BLACK_WIDTH"
text-anchor="middle"
fill="white"
>
<tspan
v-for="(line, i) of key.label"
:key="i"
:x="key.x + BLACK_WIDTH * 0.5"
:dy="0.6 * BLACK_WIDTH"
>
{{ line }}
</tspan>
</text>
</g>
</svg>
</template>

<style scoped>
svg rect.white {
fill: white;
}
svg rect.white.active {
fill: green;
}
svg rect.black {
fill: black;
}
svg rect.black.active {
fill: darkgreen;
}
</style>
3 changes: 3 additions & 0 deletions src/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,3 +13,6 @@ export const NUMBER_OF_NOTES = 128;

// Browser interaction
export const LEFT_MOUSE_BTN = 0;

// Offset such that default base MIDI note doesn't move in "simple" white mode.
export const WHITE_MODE_OFFSET = 69 - 40;
2 changes: 1 addition & 1 deletion src/midi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export function computeWhiteIndices(baseMidiNote: number, colors: string[]) {
info.whiteNumber === undefined ? info.sharpOf + 1 : info.whiteNumber;
let colorIndex = 0;
const result = [];
while (whiteIndex > 0 && index > -1024) {
while (whiteIndex >= 0 && index > -1024) {
if (colors[mmod(colorIndex--, colors.length)] !== "black") {
result[whiteIndex--] = index;
}
Expand Down
59 changes: 58 additions & 1 deletion src/views/MidiView.vue
Original file line number Diff line number Diff line change
@@ -1,14 +1,23 @@
<script setup lang="ts">
import { computed, onMounted, onUnmounted, reactive, ref } from "vue";
import { Input, Output, WebMidi, type MessageEvent } from "webmidi";
import {
Input,
Output,
WebMidi,
type NoteMessageEvent,
type MessageEvent,
} from "webmidi";
import MidiPiano from "@/components/MidiPiano.vue";
const props = defineProps<{
baseMidiNote: number;
midiInput: Input | null;
midiOutput: Output | null;
midiInputChannels: Set<number>;
midiOutputChannels: Set<number>;
midiVelocityOn: boolean;
midiWhiteMode: "off" | "simple" | "blackAverage" | "keyColors";
keyColors: string[];
}>();
const emit = defineEmits([
Expand All @@ -25,6 +34,9 @@ const outputs = reactive<Output[]>([]);
const inputHighlights = reactive<Set<number>>(new Set());
const stopHighlights = ref<() => void>(() => {});
const activeKeys = reactive<Set<number>>(new Set());
const stopActivations = ref<() => void>(() => {});
const midiVelocityOn = computed({
get: () => props.midiVelocityOn,
set: (newValue) => emit("update:midiVelocityOn", newValue),
Expand All @@ -39,6 +51,18 @@ function highlightMidiChannel(event: MessageEvent) {
setTimeout(() => inputHighlights.delete(event.message.channel), 500);
}
function activateKey(event: NoteMessageEvent) {
if (props.midiInputChannels.has(event.message.channel)) {
activeKeys.add(event.note.number);
}
}
function deactivateKey(event: NoteMessageEvent) {
if (props.midiInputChannels.has(event.message.channel)) {
activeKeys.delete(event.note.number);
}
}
function selectMidiInput(event: Event) {
const id = (event!.target as HTMLSelectElement).value;
stopHighlights.value();
Expand All @@ -52,6 +76,20 @@ function selectMidiInput(event: Event) {
stopHighlights.value = () =>
input.removeListener("midimessage", highlightMidiChannel);
}
stopActivations.value();
if (id === "no-midi-input") {
emit("update:midiInput", null);
stopActivations.value = () => {};
} else {
const input = WebMidi.getInputById(id);
emit("update:midiInput", input);
input.addListener("noteon", activateKey);
input.addListener("noteoff", deactivateKey);
stopActivations.value = () => {
input.removeListener("noteon", activateKey);
input.removeListener("noteoff", deactivateKey);
};
}
}
function selectMidiOutput(event: Event) {
Expand Down Expand Up @@ -113,11 +151,19 @@ onMounted(async () => {
input.addListener("midimessage", highlightMidiChannel);
stopHighlights.value = () =>
input.removeListener("midimessage", highlightMidiChannel);
input.addListener("noteon", activateKey);
input.addListener("noteoff", deactivateKey);
stopActivations.value = () => {
input.removeListener("noteon", activateKey);
input.removeListener("noteoff", deactivateKey);
};
}
});
onUnmounted(() => {
stopHighlights.value();
stopActivations.value();
});
</script>

Expand Down Expand Up @@ -205,6 +251,14 @@ onUnmounted(() => {
</span>
</div>
</div>
<div class="piano-container">
<MidiPiano
:baseMidiNote="baseMidiNote"
:midiWhiteMode="midiWhiteMode"
:keyColors="keyColors"
:activeKeys="activeKeys"
/>
</div>
</div>
<div class="column midi-controls">
<h2>MIDI Output</h2>
Expand Down Expand Up @@ -285,4 +339,7 @@ div.channels-wrapper span {
.active {
background-color: greenyellow;
}
div.piano-container {
height: 50%;
}
</style>

0 comments on commit 7728aa0

Please sign in to comment.