Last active
June 19, 2024 21:28
-
-
Save Desdaemon/19d0406eaa61b45e001bc23e02dad908 to your computer and use it in GitHub Desktop.
Collective Unconscious Auto Kalimba Sync
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==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