Skip to content

Instantly share code, notes, and snippets.

@Desdaemon
Last active June 19, 2024 21:28
Show Gist options
  • Select an option

  • Save Desdaemon/19d0406eaa61b45e001bc23e02dad908 to your computer and use it in GitHub Desktop.

Select an option

Save Desdaemon/19d0406eaa61b45e001bc23e02dad908 to your computer and use it in GitHub Desktop.
Collective Unconscious Auto Kalimba Sync
// ==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
// @require https://cdn.jsdelivr.net/npm/webmidi@latest/dist/iife/webmidi.iife.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;
var WebMidi = this.window.WebMidi;
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, code, keyCode) {
const event = new Event(eventType, { bubbles: true });
event.key = key;
event.code = code;
// 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, code, keyCode) {
simulateKeyboardEvent('keydown', key, code, keyCode);
setTimeout(() => {
simulateKeyboardEvent('keyup', key, code, keyCode);
}, 16);
}
// 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 sound_codes = {
"Digit1": "1",
"Digit2": "2",
"Digit3": "3",
"Digit4": "4",
"Digit5": "5",
"Digit6": "6",
"Digit7": "7",
"Digit8": "8",
"Digit9": "9",
"Digit0": "0",
"BracketLeft": "[",
"BracketRight": "]",
"ArrowLeft": "ArrowLeft",
"ArrowDown": "ArrowDown",
"ArrowRight": "ArrowRight",
}
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 = [];
//let skip = false;
setInterval(function buttonQueueHandler() {
//if (!(skip = !skip)) {
let key = buttonQueue.shift();
if (key) {
//console.log(Date.now(), key);
simulateKeyboardInput(sound_codes[key], key, _button_keycodes[key]);
}
//}
//setTimeout(buttonQueueHandler, 17);
}, 20);
//buttonQueueHandler();
let low = 48
let med = 60
let hi = 72
let max = 84
let base = 4
var current_octave = -1;
function playNote(note) {
var octave = -1;
//if (note < low) note = (note % 12) + low
//else if (note >= max) note = (note % 12) + hi
if (note >= low && note < med) {
octave = 0;
} else if (note >= med && note < hi) {
octave = 1;
} else if (note >= hi && note < max) {
octave = 2;
} else {
//console.log('note discarded', note);
return;
}
if (current_octave != octave) {
current_octave = octave;
buttonQueue.push(octave_buttons[octave]);
}
buttonQueue.push(sound_buttons[note % 12]);
}
function changeBaseOctave() {
if (++base > 8) base = 2;
low = 12 * base
med = 12 * (base + 1)
hi = 12 * (base + 2)
max = 12 * (base + 3)
document.getElementById('ak-base-octave').innerHTML = 'C' + base
}
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) {
if (event.name == "Note on") {
//console.log(event.delta, event.noteName);
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() {
triggerUpdateLoop();
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() {
triggerUpdateLoop();
Player.stop();
_disable_pause_stop();
}
function toggleKalimba() {
document.getElementById('explorerPane').classList.toggle('hidden');
}
const SYNC_OFFSET = 1000 * 30; // 30s
function setSyncTime() {
if (syncHandle) {
clearTimeout(syncHandle);
syncHandle = null;
triggerUpdateLoop();
return;
}
const syncTime = new Date(Date.now() + SYNC_OFFSET);
const syncInput = document.getElementById('ak-sync-time');
syncInput.value = syncTime.getTime();
runSync(syncTime.getTime());
}
let syncHandle;
function runSync(value = null) {
const syncInput = document.getElementById('ak-sync-time');
if (!value) {
value = (new Date(Number(syncInput.value))).getTime();
if (Number.isNaN(value)) value = (new Date(syncInput.value)).getTime();
if (!value || Number.isNaN(value)) return console.error(`${syncInput.value} could not be parsed as time.`);
}
if (syncHandle) {
clearTimeout(syncHandle);
}
syncHandle = setTimeout(play, value - Date.now());
triggerUpdateLoop(value);
}
let syncUpdateHandle;
function triggerUpdateLoop(target = null) {
const resetButton = document.getElementById('ak-reset-sync');
if (syncUpdateHandle) clearInterval(syncUpdateHandle);
syncUpdateHandle = null;
if (!target) {
resetButton.innerText = 'Sync to next 30s';
return;
}
syncUpdateHandle = setInterval(() => {
const diff = target - Date.now();
if (diff > 0) {
const seconds = Math.round(diff / 1000);
resetButton.innerText = `${seconds}s | Cancel`;
} else {
triggerUpdateLoop();
}
}, 1000);
}
let midi;
async function initMidi() {
await WebMidi.enable();
for (const input of WebMidi.inputs) {
//console.log({input});
input.removeListener('noteon');
input.addListener("noteon", midiInputHandler);
}
const status = document.getElementById('ak-midi-status');
status.innerText = 'MIDI ready';
status.removeAttribute('disabled');
}
function midiInputHandler(event) {
//console.log(event);
playNote(event.note.number);
}
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>
<button id="ak-base-octave">C4</button>
<button id="ak-midi-status" disabled>MIDI inactive</button>
</div>
<div class="ak-buttons">
<input id="ak-sync-time" placeholder="Sync timestamp">
<button id="ak-reset-sync">Sync to next 30s</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));
}
#ak-sync-time {
border: 1px solid #ccc;
}
@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;
}
let leftControls;
if (leftControls = document.getElementById('leftControls')) {
leftControls.insertAdjacentHTML('beforeend', `
<button id="hideKalimba" class="iconButton toggleButton unselectable">
AK
</button>`)
document.getElementById('hideKalimba').addEventListener('click', toggleKalimba);
}
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-base-octave').addEventListener('click', changeBaseOctave);
document.getElementById('ak-load-file-button').addEventListener('change', loadFile);
document.getElementById('ak-reset-sync').addEventListener('click', setSyncTime);
document.getElementById('ak-sync-time').addEventListener('input', () => runSync());
document.getElementById('ak-midi-status').addEventListener('click', initMidi);
initMidi();
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