|
// ==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(); |