Skip to content

Instantly share code, notes, and snippets.

@knuxify
Last active June 26, 2024 16:20
Show Gist options
  • Save knuxify/af134e6bd23893228b982927cc268309 to your computer and use it in GitHub Desktop.
Save knuxify/af134e6bd23893228b982927cc268309 to your computer and use it in GitHub Desktop.
Collective Unconscious: Auto-Kalimba

Collective Unconscious: Auto-Kalimba

Play back MIDI files using the Kalimba!

Not actively maintained, use prod's script instead: https://github.com/prodzpod/kalimba

How to install

This is an userscript; to use it, you need to have a userscript extension installed.

Tampermonkey should work fine on both Firefox and Chromium.

Once you have it installed, click here to install the userscript.

How to use

A new pane, "Auto-Kalimba", will appear below the chat box. (This pane takes the place of 2kki Explorer in 2kki; if you don't see it, you may need to resize your window or go full-screen.)

  1. Select a MIDI file with the "Browse" button.
  2. Switch to the Kalimba effect, open the Kalimba UI (1 key)
  3. Press "Play" to start playing!

Getting MIDIs

There are plenty of MIDIs made specifically for solo instrument use; you can get them e.g. from the repository at https://songs.bardmusicplayer.com/ (look for tracks marked as "Solo").

TODO

  • Support for selecting a channel (in multi-channel MIDIs)
  • Orchestra mode
  • MIDI preview before playing

Known issues

  • If a MIDI has multiple channels (instruments), all of them will be played. This will be fixed soon:tm:.
  • If you click out of the tab, the notes will stop playing; this is due to the way notes are queued. This may be fixed in a future update. (Note that you can click around the window and use the chat while you play.)

How it works

We parse the MIDI with the MidiPlayerJS library, and queue the notes as key presses, which are sent directly to the game (the same way that the gamepad does this).

// ==UserScript==
// @name Collective Unconscious: Auto-Kalimba
// @namespace http://tampermonkey.net/
// @version 2024-06-09
// @description Play back MIDI files using the Kalimba!
// @author knuxify / unk_f000
// @license MIT
// @match https://ynoproject.net/unconscious/
// @icon https://www.google.com/s2/favicons?sz=64&domain=ynoproject.net
// @grant none
// @require https://grimmdude.com/MidiPlayerJS/browser/midiplayer.js
// ==/UserScript==
/*
* This script uses the MidiPlayerJS library (https://github.com/grimmdude/MidiPlayerJS)
* for parsing MIDIs. Thanks a lot to the author of that library!
*/
var MidiPlayer = MidiPlayer;
const canvas = document.getElementById('canvas');
/*
* The following two functions are taken from forest-orb's source code
* (the second function is slightly modified to change the keyup speed).
*/
/**
* Simulate a keyboard event on the emscripten canvas
*
* @param {string} eventType Type of the keyboard event
* @param {string} key Key to simulate
* @param {number} keyCode Key code to simulate (deprecated)
*/
function simulateKeyboardEvent(eventType, key, keyCode) {
const event = new Event(eventType, { bubbles: true });
event.key = key;
event.code = key;
// Deprecated, but necessary for emscripten somehow
event.keyCode = keyCode;
event.which = keyCode;
canvas.dispatchEvent(event);
}
/**
* Simulate a keyboard input from `keydown` to `keyup`
*
* @param {string} key Key to simulate
* @param {number} keyCode Key code to simulate (deprecated)
*/
function simulateKeyboardInput(key, keyCode) {
simulateKeyboardEvent('keydown', key, keyCode);
window.setTimeout(() => {
simulateKeyboardEvent('keyup', key, keyCode);
}, 17);
}
// Button keycodes appear to be deprecated, and generally don't need
// to be passed to get the code to work. Nonetheless, they are included
// here for completeness.
const _button_keycodes = {
"Digit1": 49,
"Digit2": 50,
"Digit3": 51,
"Digit4": 52,
"Digit5": 53,
"Digit6": 54,
"Digit7": 55,
"Digit8": 56,
"Digit9": 57,
"Digit0": 48,
"BracketLeft": 219,
"BracketRight": 221,
"ArrowLeft": 37,
"ArrowDown": 40,
"ArrowRight": 39,
};
const sound_buttons = ["Digit1", "Digit2", "Digit3", "Digit4", "Digit5", "Digit6", "Digit7", "Digit8", "Digit9", "Digit0", "BracketLeft", "BracketRight"]
const octave_buttons = ["ArrowLeft", "ArrowDown", "ArrowRight"]
/*
* There's a notable limitation to take into account:
* We can only do one key press per frame.
* The engine runs at 60 FPS (or so I've been told), so one frame equals
* 1/60 s == 16.6666... ms.
*
* In order to avoid dropping notes, we queue them up and play them in
* order. The note parsing happens in playNote, and the actual toggling
* of the note is handed off to the queue.
*/
var buttonQueue = [];
function sleep(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
function buttonQueueHandler() {
let key, keyCode;
key = buttonQueue.shift();
if (key) {
simulateKeyboardInput(key, keyCode);
}
window.requestAnimationFrame(buttonQueueHandler);
}
window.requestAnimationFrame(buttonQueueHandler);
var current_octave = -1;
function playNote(note) {
var octave = -1;
if (note >= 48 && note < 60) {
octave = 0;
} else if (note >= 60 && note < 72) {
octave = 1;
} else if (note >= 72 && note < 84) {
octave = 2;
} else {
console.log("Unhandled note:".concat(" ", note.toString()));
return;
}
if (current_octave != octave) {
current_octave = octave;
buttonQueue.push(octave_buttons[octave]);
}
buttonQueue.push(sound_buttons[note % 12]);
}
function prettyTime(seconds) {
if (seconds >= 3600) {
return new Date(seconds * 1000).toISOString().slice(11, 19);
}
return new Date(seconds * 1000).toISOString().slice(14, 19);
};
function handleMidiEvent(event) {
// console.log(event);
if (event.name == "Note on") {
playNote(event.noteNumber);
}
}
var Player = new MidiPlayer.Player(handleMidiEvent);
function updatePlayBar() {
if (Player.getSongTime() > 0) {
document.getElementById('ak-play-bar').style.width = 100 - Player.getSongPercentRemaining() + '%';
document.getElementById('ak-playtime-current').innerHTML = prettyTime(Player.getSongTime() - Player.getSongTimeRemaining());
}
setTimeout(updatePlayBar, 500);
}
updatePlayBar();
function play() {
Player.play();
document.getElementById('akStatusIcon').classList.add("active");
document.getElementById('akStatusIcon').classList.remove("paused");
document.getElementById('akStatusLabel').innerHTML = "Playing";
document.getElementById('ak-pause-button').removeAttribute("disabled");
document.getElementById('ak-stop-button').removeAttribute("disabled");
}
function pause() {
Player.pause();
document.getElementById('akStatusIcon').classList.remove("active");
document.getElementById('akStatusIcon').classList.add("paused");
document.getElementById('akStatusLabel').innerHTML = "Paused";
}
function _disable_pause_stop() {
document.getElementById('akStatusIcon').classList.remove("active");
document.getElementById('akStatusIcon').classList.remove("paused");
document.getElementById('akStatusLabel').innerHTML = "Not Playing";
document.getElementById('ak-pause-button').setAttribute("disabled", "");
document.getElementById('ak-stop-button').setAttribute("disabled", "");
document.getElementById('ak-play-bar').style.width = 0;
document.getElementById('ak-playtime-current').innerHTML = "00:00";
}
function stop() {
Player.stop();
_disable_pause_stop();
}
Player.on("endOfFile", _disable_pause_stop);
function loadFile() {
Player.stop();
var file = document.getElementById('ak-load-file-button').files[0];
var reader = new FileReader();
if (file) reader.readAsArrayBuffer(file);
reader.addEventListener("load", function () {
Player = new MidiPlayer.Player(handleMidiEvent);
Player.loadArrayBuffer(reader.result);
document.getElementById('ak-play-button').removeAttribute('disabled');
document.getElementById('ak-playtime-total').innerHTML = prettyTime(Player.getSongTime());
}, false);
}
function loadDataUri(dataUri) {
Player = new MidiPlayer.Player(handleMidiEvent);
Player.loadDataUri(dataUri);
}
/* We re-use 2kki Explorer code to add a pane below the chatbox: */
const _container = `<div id="explorerContainer">`;
const _container_end = '</div>';
const pane = `<div id="explorerPane" class="ak-pane">
<div id="ak-header" class="unselectable">
<span style="font-weight: bold;">Auto-Kalimba</span>
<div class="infoContainer">
<span id="akStatusIcon">●</span>
<span id="akStatusLabel" style="font-weight: bold;" class="infoText">Not Playing</span>
</div>
</div>
<input type="file" id="ak-load-file-button"/>
<div id="ak-trackinfo">
<div id="ak-play-bar-container">
<div id="ak-play-bar" style="width: 0%;"></div>
</div>
<div class="ak-play-time">
<span id="ak-playtime-current">--:--</span> / <span id="ak-playtime-total">--:--</span>
</div>
</div>
<div class="ak-buttons">
<button id="ak-play-button" disabled>Play</button>
<button id="ak-pause-button" disabled>Pause</button>
<button id="ak-stop-button" disabled>Stop</button>
</div>
</div>
<style>
.ak-pane { display: flex; flex-direction: column; gap: 6px; }
#ak-load-file-button {
max-width: 100%;
box-sizing: border-box;
}
#akStatusIcon {
background: linear-gradient(#b30000, #800000);
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
background-clip: text;
}
#akStatusIcon.active {
background-image: linear-gradient(#00b33c, #00802b);
}
#akStatusIcon.paused {
background-image: linear-gradient(#b35400, #804800);
}
#ak-header {
display: flex;
justify-content: space-between;
}
#ak-trackinfo {
display: flex;
align-items: center;
gap: 6px;
}
#ak-play-bar-container {
border:1px solid #ccc;
background-color: rgb(var(--base-bg-color));
box-sizing: border-box;
flex: 1 0 auto;
}
#ak-play-bar {
height: 20px;
background-color: rgb(var(--base-color));
}
@media only screen and (max-width: 1050px) and (min-height: 595px) {
#ak-header, #ak-trackinfo {
display: block;
}
#explorerContainer {
margin-top: 0 !important;
}
}
</style>`;
const _full_pane = _container + pane + _container_end;
function autokalimba_setup() {
console.log("Setting up Auto-Kalimba...");
if (document.getElementById('explorerContainer')) {
document.getElementById('explorerContainer').innerHTML = pane;
} else {
document.getElementById('layout').classList.add("explorer");
document.getElementById('chatboxContainer').innerHTML += _full_pane;
}
document.getElementById('ak-play-button').addEventListener('click', play);
document.getElementById('ak-pause-button').addEventListener('click', pause);
document.getElementById('ak-stop-button').addEventListener('click', stop);
document.getElementById('ak-load-file-button').addEventListener('change', loadFile);
console.log("Auto-Kalimba is enabled!");
}
autokalimba_setup();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment