MIDI output for Online Sequencer

Output sequences to a MIDI device. (This is WIP so it's not pretty)

  1. // ==UserScript==
  2. // @name MIDI output for Online Sequencer
  3. // @namespace http://tampermonkey.net/
  4. // @version 2025-01-10
  5. // @description Output sequences to a MIDI device. (This is WIP so it's not pretty)
  6. // @author Ethan McCoy
  7. // @match https://onlinesequencer.net/*
  8. // @icon https://www.google.com/s2/favicons?sz=64&domain=onlinesequencer.net
  9. // @grant none
  10. // @license MIT
  11. // ==/UserScript==
  12.  
  13. //Load window, and various functions and classes that we are going to modify
  14. (function(window, playNote, midiNoteNamesToIndex, settings, song, AudioSystemInstrument) {
  15. 'use strict';
  16.  
  17.  
  18. function setInstrumentChannel (ch){
  19. //Assuming 'this' will be the instance
  20. this.midiChannel = ch;
  21.  
  22. // Hijacking the alpha channel of the instument color as a lazy way to save the midi channel into Sequence Files
  23. // This means the color in the dropdown menu could be as low as 0.9372549019607843 alpha (for channel 16), which hopefully isn't noticable
  24.  
  25. //Explicitly set color so it is saved
  26. this.color[0]
  27. }
  28.  
  29. function removeInstrumentChannel (){
  30. this.midiChannel = undefined;
  31. }
  32.  
  33.  
  34.  
  35.  
  36. //I guess js lets us just mess with the class prototype even after an instance has been created and it will get those methods
  37. AudioSystemInstrument.prototype.setMidiChannel = setInstrumentChannel;
  38. AudioSystemInstrument.prototype.unsetMidiChannel = removeInstrumentChannel;
  39.  
  40. function sendNote(midiAccess, portID, note, delay=0, length, velocityOn, velocityOff=1, channel=1) {
  41. channel--
  42. if (isFinite(midiNoteNamesToIndex[note])) { //online sequencer is using for i in array so this function is getting array methods that we want to ignore
  43. var now = window.performance.now();
  44. var noteOnDelay = now + delay * 1000;
  45. if (typeof length === 'undefined'){ // if it's zero we want to keep it zero eg. https://onlinesequencer.net/1859745 Angry Young Man has a lot of zero length C7 notes that shouldn't be there
  46. length = 1000; //making it one second if no length just because I feel like it. I'll fix it later maybe TODO length appears to be in quarter notes
  47. }
  48. var noteOffDelay = now + delay * 1000 + length;
  49. const noteOnMessage = [0x90 + channel, midiNoteNamesToIndex[note], Math.ceil(0x7f*velocityOn)]; // note on, note passed in, 'volume' is 0 to 1
  50. const noteOffMessage = [0x80 + channel, midiNoteNamesToIndex[note], Math.ceil(0x7f*velocityOff)]; // note off, note passed in, full velocity by default
  51. const output = midiAccess.outputs.get(portID);
  52. if (output && length !== 0){
  53. output.send(noteOnMessage, noteOnDelay); // sends the message.
  54. if (!isNaN(noteOffDelay)) output.send(noteOffMessage, noteOffDelay-50); // sends the message. -1 to avoid sending noteOn at the exact same time
  55. }
  56. }
  57. }
  58.  
  59. var instIdToMidiOut = {43:[1, 'output-1'],
  60. 41:[1, 'output-1']};//Rather than a 1:1 table, should have instruments pass a test to see which devices and channels they go to
  61. function getMidiOutput (instId, testFn, ch){
  62. if (testFn(instId)){
  63. return ch;
  64. }else{
  65. return null;
  66. }
  67. }
  68.  
  69. var useSequenceChannels = false;
  70. var useDefaultChannels = false; // If true, will be used instead of userPrefs, if false, will only be used when userPrefs not present
  71.  
  72. //take an instId and midiaccess outputs map, and return output and channel for that instId
  73. function assignInstrumentToMidiOutput(instId, outputs){
  74. //{instId: [ch, outputId]}
  75. var userPrefs = localStorage.getItem('midiOutputTable');
  76.  
  77. if (!useSequenceChannels && userPrefs != null){// use userPrefs exist and we want to use them
  78. instIdToMidiOut = userPrefs;
  79. }
  80.  
  81. }
  82.  
  83.  
  84. //TODO add Instrument categories, MIDI channels, MIDI Devices
  85. function addInstrumentMIDIChannelUI (song, parentDiv){
  86. //If we are in play_mode
  87. //for each instrument in the song, populate a drop down menu.
  88. //these settings won't be saved, because it's someone else's sequence, but we can still attach the channels to the notes for this session, and save them if we enter edit mode
  89.  
  90. var midiMenuInner = '';
  91. //construct the html
  92. song.forEachInstrument(function(instId){
  93. //If instument in a group. add that group if not already added, and put instrument in it
  94. /* midiMenu +=
  95. `<li class="ui-selectmenu-optgroup ui-menu-divider">
  96. <i class="fas fa-piano-keyboard"></i>Piano
  97. </li>
  98. `
  99. */
  100. const instrumentName = getInstrumentName(song, instId);//settings.instruments[instId];
  101. //var li = document.createElement("li").appendChild(document.createElement("div"))
  102. midiMenuInner +=`<option value="${instId}">${instrumentName}</option>`
  103.  
  104. });
  105.  
  106. var midiMenu = document.createElement('select');
  107. midiMenu.innerHTML = midiMenuInner;
  108. console.log('midiMenu', midiMenu);
  109. midiMenu.id = "midiMenu";
  110. midiMenu.style.display = "none";
  111.  
  112.  
  113. //If we are in edit_mode
  114. //There's already a drop down menu for ALL instruments, check if there's any instruments with channels and append their entries,
  115. //TODO create editable 'default' channels for instruments. ie, so you can just load a song and all 'electric piano' or whatever goes to channel 1. multiple instruments per channel is possible
  116.  
  117. var enableOutput = document.createElement('input');
  118. enableOutput.type = "checkbox";
  119. enableOutput.id = "enableMidiOutput";
  120. var enableOutputLabel = document.createElement('label');
  121. enableOutputLabel.for = "enableMidiOutput";
  122. enableOutputLabel.innerText = "🎹";
  123. //muteIfMidiCheckbox.onClick=
  124. parentDiv.appendChild(enableOutputLabel);
  125. parentDiv.appendChild(enableOutput);
  126.  
  127. //TODO, put all this into a control panel like the advanced instruments, right now it's checkboxes with labels
  128.  
  129. var muteIfMidiCheckbox = document.createElement('input');
  130. muteIfMidiCheckbox.type = "checkbox";
  131. muteIfMidiCheckbox.id = "muteMidi";
  132. var muteMidiLabel = document.createElement('label');
  133. muteMidiLabel.for = "muteMidi";
  134. muteMidiLabel.innerText = "🔇"; //Mute in browser if instrument is being sent to midi device
  135. //muteIfMidiCheckbox.onClick=
  136. parentDiv.appendChild(muteMidiLabel);
  137. parentDiv.appendChild(muteIfMidiCheckbox);
  138.  
  139. parentDiv.appendChild(midiMenu);
  140. $("#midiMenu").selectmenu({
  141. change: function( event, ui ) {
  142. selectInstrument(ui.item.element.context.value);
  143. //console.log(event, ui);
  144. }
  145. });
  146.  
  147. //$("#muteMidi").button();
  148.  
  149. }
  150.  
  151. window.addInstrumentMIDIChannelUI = addInstrumentMIDIChannelUI;
  152.  
  153. window.addEventListener('load', function() {
  154. var oldPlayNote = oldPlayNote||playNote; // if we've already assigned playNote to oldPlayNote, don't do it again or it will be playNotes all the way down
  155.  
  156. //song.usesInstrument(43)); throws error because 'this' is undefined
  157.  
  158. //console.log("Number of 'Electric Piano' notes:", song.numNotesPerInstrument.get(43));
  159.  
  160.  
  161. navigator.requestMIDIAccess().then(function(access){
  162. console.log("Access established");
  163. // Just going to get the first id now and later we will populate a UI and let user select
  164. const firstMidiOutputId = access.outputs.keys().next().value;
  165. //const firstMidiOutputId = 'output-2';
  166.  
  167. var enabled = false;
  168. window.playNote = function(instId, name, length, delay, keyHighlight=!0, volume=1, track=!1){
  169. //oldPlayNote(instId, name, length, delay, keyHighlight, volume, track);
  170. // 41 is the piano, so here we are assuming 'output-1' is a piano TODO: when we add controls, we should allow user to pair up midi outputs to instruments
  171. // 43 electric piano
  172. enabled = document.getElementById("enableMidiOutput")?.checked;
  173. if (enabled){
  174. var muteWhenMidi = document.getElementById("muteMidi")?.checked;
  175.  
  176. //var ch = instIdToMidiOut[instId][0];
  177. var ch = getMidiOutput(instId, (instId) => settings.instrumentCategories.Piano.includes(instId), 1);
  178. var outputId = (instIdToMidiOut[instId] && instIdToMidiOut[instId][1])||firstMidiOutputId;
  179.  
  180. //getMidiOutput(instId,
  181. //if (instId == 41||instId == 43) {
  182. // if (settings.instrumentCategories.Piano.includes(instId)){ // is it a piano note?
  183. if (ch>0){
  184. sendNote(access, outputId, name, delay, length * song.sleepTime, Math.min(volume/2,1), 1, ch);//Volume goes from 0 to 2, with 1 being the default. Lets just say volume 1 is 0.5 velocity TODO: Stretch goal, editable interpolation curve instead of linear
  185. }else{
  186. oldPlayNote(instId, name, length, delay, keyHighlight, volume, track);
  187. }
  188.  
  189. //Play the note as usual
  190. if (!muteWhenMidi && ch>0){
  191. oldPlayNote(instId, name, length, delay, keyHighlight, volume, track);
  192. }
  193. }else{
  194. oldPlayNote(instId, name, length, delay, keyHighlight, volume, track);
  195. }
  196. };
  197. })
  198.  
  199. addInstrumentMIDIChannelUI(song, document.getElementById("top-bar-right")); //("titlebar"));
  200.  
  201. }, false);
  202.  
  203.  
  204. })(window, playNote, midiNoteNamesToIndex, settings, song, AudioSystemInstrument);