From c1189d835a17ffac135c583e855234800ae6e14d Mon Sep 17 00:00:00 2001 From: hikari_no_yume Date: Sat, 2 Mar 2024 22:29:35 +0100 Subject: [PATCH] Add MIDI output support (with some limitations) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds support for MIDI output, so you can use the autokalimba to play an external synth! This uses Web MIDI, which is available in Chrome and Firefox currently. In my experience, it works best in Chrome. I tested this with my Roland SC-7. That's a General MIDI device with no panel controls, hence me adding dropdown and buttons for sending Program Changes for my convenience, but they're useful for other devices too. Some features aren't supported yet: - Tuning. Probably easy to add using the RPN for Fine Tuning. - Strumming. This would need a scheduling system so stums can be cancelled without sending a Note Off too late or too early. - Changing the voicing of a chord while it is playing, either due to a new bass note or due to bending. This is tricky because, when using just a single MIDI channel, there's no standard way to change a note after it has begun. Restarting the note (Note Off followed by Note On) doesn't sound good. We could add a mode where we use one channel per chord to allow per-note pitch bends, à la MIDI Polyphonic Expression? In line with many MIDI instruments, the MIDI output is independent of the normal synthesis output. They can play together in a duet! 🎶 --- autokalimba.js | 178 +++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 168 ++++++++++++++++++++++++++++++++++++++++++++++ style.css | 8 +++ 3 files changed, 354 insertions(+) diff --git a/autokalimba.js b/autokalimba.js index e3f574f..506e8b7 100644 --- a/autokalimba.js +++ b/autokalimba.js @@ -18,7 +18,9 @@ cps.connect(mix); mix.connect(ctx.destination); mix.gain.value = 1.0; const pointers = new Map(); +const A440MIDINote = 69; let currentBass = 220.0; +let currentBassMIDINote = A440MIDINote - 12; // let frozenBass = 220.0; let sampleBuffers = []; let strumSetting = 0.04; @@ -32,6 +34,8 @@ let lastVoicing = undefined; let lastBassTime = Date.now(); let forceFifthInBass = false; +let midiOpenDevice = null; + function subSemitones() { // If the last voicing contains b5 or #5, drop a tritone; otherwise, drop a fourth. return lastVoicing && lastVoicing.some((v) => v % 12 === 6 || v % 12 === 8) @@ -150,6 +154,25 @@ function chordFreq(semitones) { return k; } +function chordMIDINote(semitones) { + if (bend) { + if (semitones === 3 || semitones === 4) semitones = 2; + if (semitones === 15 || semitones === 16) semitones = 14; + } + + const chordOctave = +$("#midi-chord-octave").value; + const bassOctave = +$("#midi-bass-octave").value; + + let k = currentBassMIDINote + (chordOctave - bassOctave) * 12 + semitones; + + const chordRangeStart = +$("#midi-chord-range-start").value; + const chordRangeSize = +$("#midi-chord-range-size").value; + + if (k < A440MIDINote + (chordOctave - 4) * 12 + chordRangeStart) k += 12; + if (k > A440MIDINote + (chordOctave - 4) * 12 + chordRangeStart + chordRangeSize) k -= 12; + return k; +} + function bassFreq(semitones) { const st = semitones + getTuningSemitones(); const base = Number($("#base").value); @@ -157,6 +180,13 @@ function bassFreq(semitones) { return 110 * 2 ** (wrapped / 12); } +function bassMIDINote(semitones) { + const st = semitones; + const base = Number($("#base").value); + const wrapped = ((st + 1200 - base) % 12) + base; + return A440MIDINote + ($("#midi-bass-octave").value - 4) * 12 + wrapped; +} + function noteNameToSemitone(note) { return ( "A BC D EF G ".indexOf(note.charAt(0)) + /♯|#/.test(note) - /♭|b/.test(note) @@ -230,6 +260,11 @@ function recomputeKeyLabels() { }); } +function midiPlayNote(channel, note, velocity) { + // Mask potentially out-of-range values just in case. + midiOpenDevice.send([0x90 | (channel & 0xF), note & 0x7F, velocity & 0x7F]); +} + window.addEventListener("DOMContentLoaded", (event) => { if (/harp/.test(window.location.href)) $(".refresh-link").remove(); recomputeKeyLabels(); @@ -323,6 +358,15 @@ window.addEventListener("DOMContentLoaded", (event) => { e.target.style.background = "#f80"; } + const midiNotes = []; + if (midiOpenDevice !== null) { + currentBassMIDINote = bassMIDINote(semitones); + const channel = +$("#midi-bass-channel").value; + const time = performance.now(); + midiPlayNote(channel, currentBassMIDINote, 100); + midiNotes.push([channel, currentBassMIDINote]); + } + pointers.set(e.pointerId, { centerX: centerX, centerY: centerY, @@ -332,6 +376,7 @@ window.addEventListener("DOMContentLoaded", (event) => { rootSemitone: noteNameToSemitone(note), isSub, oscs: [makeOsc(freq, 0.5 * bassGain, 0, true)], + midiNotes, }); // Correct chord voicing to this new bass note @@ -357,6 +402,15 @@ window.addEventListener("DOMContentLoaded", (event) => { osc.gainNode.gain.setTargetAtTime(0, ctx.currentTime + 0.05, 0.01); osc.stop(ctx.currentTime + 0.2); } + + const midiNotes = []; + if (midiOpenDevice !== null) { + const time = performance.now(); + for (let [channel, note] of p.midiNotes) { + midiPlayNote(channel, note, 0 /* Note Off */); + } + } + p.target.style.background = ""; pointers.delete(pointerId); } @@ -385,6 +439,16 @@ window.addEventListener("DOMContentLoaded", (event) => { lastFreqs = freqs; } + const midiNotes = []; + if (midiOpenDevice !== null) { + const channel = +$("#midi-chord-channel").value; + for (let voice of voicing) { + const midiNote = chordMIDINote(voice); + midiPlayNote(channel, midiNote, 100); + midiNotes.push([channel, midiNote]); + } + } + pointers.set(e.pointerId, { centerX: rect.left + rect.width / 2, centerY: rect.top + rect.height / 2, @@ -408,6 +472,7 @@ window.addEventListener("DOMContentLoaded", (event) => { : 0; return makeOsc(freq, 0.2 * chordGain, delay, false); }), + midiNotes, }); // Correct bass sub to this new chord voicing @@ -597,4 +662,117 @@ window.addEventListener("DOMContentLoaded", (event) => { window.localStorage.setItem(key, String(e.target.value)); }); }); + + // Web MIDI setup. + // This part of the MIDI code was originally written for + // https://github.com/hikari-no-yume/SoundPalette, so if there's a problem + // with it, please file a bug there too. + const midiEnableButton = $("#midi-enable"); + midiEnableButton.disabled = true; + const midiDevicesDropdown = $("#midi-devices"); + midiDevicesDropdown.disabled = true; + const midiDevices = []; + const midiDeviceConnectButton = $("#midi-device-connect"); + midiDeviceConnectButton.disabled = true; + const midiOptionsZone = $("#midi-options"); + midiOptionsZone.disabled = true; + if (!navigator.requestMIDIAccess) { + $("#midi-setup").innerText = "Your browser does not support Web MIDI."; + } else { + midiEnableButton.disabled = false; + midiEnableButton.addEventListener("click", () => { + midiEnableButton.innerText = "(Requesting permission)"; + midiEnableButton.disabled = true; + navigator.requestMIDIAccess({ + software: true, + }).then((midiAccess) => { + midiEnableButton.innerText = "(MIDI enabled)"; + midiDevicesDropdown.innerHTML = ""; + let option = document.createElement("option"); + option.value = ""; + option.innerText = "(please select a device)"; + midiDevicesDropdown.appendChild(option); + midiDevicesDropdown.required = true; + for (let device of midiAccess.outputs.values()) { + option = document.createElement("option"); + option.value = midiDevices.length; + option.innerText = device.name + " (output)"; + midiDevicesDropdown.appendChild(option); + midiDevices.push(device); + } + midiDevicesDropdown.onchange = () => { + midiDeviceConnectButton.disabled = (midiDevicesDropdown.value === ""); + }; + midiDevicesDropdown.disabled = false; + midiDevicesDropdown.focus(); + }).catch((error) => { + midiEnableButton.innerText = "(Can't enable MIDI)"; + console.log(error); + }); + }); + midiDeviceConnectButton.addEventListener("click", () => { + midiDeviceConnectButton.disabled = true; + midiDevicesDropdown.disabled = true; + midiOptionsZone.disabled = true; + if (midiOpenDevice) { + midiDeviceConnectButton.innerText = "(disconnecting)"; + midiOpenDevice.close().then(() => { + midiDeviceConnectButton.disabled = false; + midiDeviceConnectButton.innerText = "Connect"; + midiDevicesDropdown.disabled = false; + }).catch((e) => { + alert("Couldn't close MIDI device?! " + e); + }); + midiOpenDevice = null; + } else { + midiDeviceConnectButton.innerText = "(connecting)"; + let newDevice = midiDevices[midiDevicesDropdown.value]; + newDevice.open().then(() => { + midiOpenDevice = newDevice; + midiDeviceConnectButton.disabled = false; + midiDeviceConnectButton.innerText = "Disconnect"; + midiOptionsZone.disabled = false; + }).catch((e) => { + alert("Could not connect to MIDI device: " + e); + midiDeviceConnectButton.disabled = false; + midiDeviceConnectButton.innerText = "Connect"; + midiDevicesDropdown.disabled = false; + }); + } + }); + } + + // MIDI options + $("#midi-bass-channel").oninput = $("#midi-bass-channel").onchange = (e) => { + $("#midi-bass-channel-value").innerText = (+e.target.value) + 1; + }; + $("#midi-chord-channel").oninput = $("#midi-chord-channel").onchange = (e) => { + $("#midi-chord-channel-value").innerText = (+e.target.value) + 1; + }; + $("#midi-bass-octave").value = 2; // reset so it matches currentBassMIDINote + $("#midi-bass-octave-value").innerText = 2; + $("#midi-bass-octave").oninput = $("#midi-bass-octave").onchange = (e) => { + $("#midi-bass-octave-value").innerText = +e.target.value; + }; + $("#midi-chord-octave").value = 4; + $("#midi-chord-octave-value").innerText = 4; + $("#midi-chord-octave").oninput = $("#midi-chord-octave").onchange = (e) => { + $("#midi-chord-octave-value").innerText = +e.target.value; + }; + $("#midi-chord-range-start").oninput = $("#midi-chord-range-start").onchange = (e) => { + $("#midi-chord-range-start-value").innerText = ["C", "C#", "D", "Eb", "E", "F", "F#", "G", "Ab", "A", "Bb", "B"][+e.target.value + 9]; + }; + $("#midi-chord-range-size").oninput = $("#midi-chord-range-size").onchange = (e) => { + $("#midi-chord-range-size-value").innerText = +e.target.value; + }; + $("#midi-pc-send-bass").addEventListener("click", () => { + let channel = $("#midi-bass-channel").value; + let programNumber = $("#midi-pc").value; + midiOpenDevice.send([0xC0 | (channel & 0xF), programNumber & 0x7F]); + }); + $("#midi-pc-send-chord").addEventListener("click", () => { + let channel = $("#midi-chord-channel").value; + let programNumber = $("#midi-pc").value; + midiOpenDevice.send([0xC0 | (channel & 0xF), programNumber & 0x7F]); + }); }); diff --git a/index.html b/index.html index 40ec6fb..0d45010 100644 --- a/index.html +++ b/index.html @@ -79,6 +79,174 @@
+
+ Output to MIDI device + + +
+
+ + + 1 +
+ + + 2 +
+ + + 1 +
+ + + 2 +
+ + + C +
+ + + 16 +
+
+ + +
+ + +
+
+
By Lynn and friends.
Donate / Report issue diff --git a/style.css b/style.css index 654b1ed..28b8ce9 100644 --- a/style.css +++ b/style.css @@ -191,3 +191,11 @@ label[for="settings"] { #horizontal-chords:checked ~ .controls .chord-column { flex-direction: row; } + +fieldset { + display: inline-block; +} +#midi-options { + border: 0; + padding: 0; +}