├── .gitattributes ├── 3rdParty ├── LICENSE ├── README.md ├── ccynthmata.js └── example.html /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /3rdParty: -------------------------------------------------------------------------------- 1 | CCynthmata Includes code from the following other projects: 2 | 3 | base64-js 4 | Copyright (c) 2014 Jameson Little 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy 7 | of this software and associated documentation files (the "Software"), to deal 8 | in the Software without restriction, including without limitation the rights 9 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 10 | copies of the Software, and to permit persons to whom the Software is 11 | furnished to do so, subject to the following conditions: 12 | 13 | The above copyright notice and this permission notice shall be included in 14 | all copies or substantial portions of the Software. 15 | 16 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 17 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 18 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 19 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 20 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 21 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 22 | THE SOFTWARE. -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2019 oscillatorsink 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # ccynthmata 2 | Generalized module for creating WebMIDI CC Controllers 3 | 4 | *This is still very much in beta right now - although I don't expect to make breaking changes at this point, I'm not making promises* 5 | 6 | ## Quickstart: 7 | Include the library, initialise it during `onload`: 8 | 9 | ```html 10 | 11 | 12 | 13 | 14 | 19 | 20 | 21 | 22 | 23 | 24 | ``` 25 | 26 | Add some `div`s for the standard interface elements to be added to: 27 | 28 | ```html 29 |
30 |

Midi Device Setup

31 |
32 |
33 |

Save/Load/Export/Share

34 |
35 | ``` 36 | 37 | Add controls with the `midiccparam` class, give it a CC number to control with the `data-cclsb` attribute: 38 | 39 | ```html 40 | 43 | ``` 44 | 45 | If you need to target a specific channel, rather than the one selected in the interface use `data-midiChannel` 46 | 47 | ```html 48 | 52 | ``` 53 | 54 | If you have 14-bit CCs specify the MSB CC with `data-ccmsb` 55 | 56 | ```html 57 | 61 | ``` 62 | 63 | For a more complete working example see my [Volca Drum Editor](https://github.com/synthmata/synthmata.github.io/tree/master/volca-drum) 64 | 65 | ## Constructor Options 66 | The `Ccynthmata` contructor optionally takes an `options` object as an argument, which may contain the following options: 67 | * `interfaceRoot`: element for the root of the interface (default: `document`) 68 | * `setupPanelElement`: element for the midi setup panel (default: the element that matches `#midiSetup` within `interfaceRoot`) 69 | * `saveLoadPanelElement`: element for the save/share panel (default: the element that matches `#saveLoadPanel` within `interfaceRoot`) 70 | * `parameterDisplayElement`: element for parameter value display (default: the element that matches `#ccParameterDisplay` within `interfaceRoot`) 71 | * `autoHideParameterDisplay`: hide the parameter display when it is not being shown (after a timeout) (default: `false`) 72 | * `initPatch`: intial patch, in the same format as the output of `collectPatch()` (default: `null`) 73 | 74 | 75 | ## TODO: 76 | * Write the rest of the documentation 77 | * Parameter display. 78 | 79 | ## Known Issues / Notes / Caveats 80 | * midiCcTotal only works properly for checkable controls (radio-buttons are the obvious use-case). This may be a feature rather than a bug... 81 | -------------------------------------------------------------------------------- /ccynthmata.js: -------------------------------------------------------------------------------- 1 | // Start: Base64-js 2 | (function(r){if(typeof exports==="object"&&typeof module!=="undefined"){module.exports=r()}else if(typeof define==="function"&&define.amd){define([],r)}else{var e;if(typeof window!=="undefined"){e=window}else if(typeof global!=="undefined"){e=global}else if(typeof self!=="undefined"){e=self}else{e=this}e.base64js=r()}})(function(){var r,e,t;return function r(e,t,n){function o(i,a){if(!t[i]){if(!e[i]){var u=typeof require=="function"&&require;if(!a&&u)return u(i,!0);if(f)return f(i,!0);var d=new Error("Cannot find module '"+i+"'");throw d.code="MODULE_NOT_FOUND",d}var c=t[i]={exports:{}};e[i][0].call(c.exports,function(r){var t=e[i][1][r];return o(t?t:r)},c,c.exports,r,e,t,n)}return t[i].exports}var f=typeof require=="function"&&require;for(var i=0;i0){throw new Error("Invalid string. Length must be a multiple of 4")}return r[e-2]==="="?2:r[e-1]==="="?1:0}function c(r){return r.length*3/4-d(r)}function v(r){var e,t,n,i,a;var u=r.length;i=d(r);a=new f(u*3/4-i);t=i>0?u-4:u;var c=0;for(e=0;e>16&255;a[c++]=n>>8&255;a[c++]=n&255}if(i===2){n=o[r.charCodeAt(e)]<<2|o[r.charCodeAt(e+1)]>>4;a[c++]=n&255}else if(i===1){n=o[r.charCodeAt(e)]<<10|o[r.charCodeAt(e+1)]<<4|o[r.charCodeAt(e+2)]>>2;a[c++]=n>>8&255;a[c++]=n&255}return a}function l(r){return n[r>>18&63]+n[r>>12&63]+n[r>>6&63]+n[r&63]}function h(r,e,t){var n;var o=[];for(var f=e;fd?d:u+a))}if(o===1){e=r[t-1];f+=n[e>>2];f+=n[e<<4&63];f+="=="}else if(o===2){e=(r[t-2]<<8)+r[t-1];f+=n[e>>10];f+=n[e>>4&63];f+=n[e<<2&63];f+="="}i.push(f);return i.join("")}},{}]},{},[])("/")}); 3 | // End: Base64-js 4 | 5 | const CCYNTHMATA_VERSION = "0.3.0"; 6 | 7 | const MIDI_CC_PARAM_CLASS = "midiccparam"; 8 | const MIDI_CC_TOTAL_CLASS = "midicctotal"; 9 | const MIDI_CC_PARAM_CLASS_SELECTOR = "." + MIDI_CC_PARAM_CLASS; 10 | const MIDI_CC_TOTAL_CLASS_SELECTOR = "." + MIDI_CC_TOTAL_CLASS; 11 | const DEFAULT_SETUP_PANEL_SELECTOR = "#midiSetup"; 12 | const DEFAULT_SAVELOAD_PANEL_SELECTOR = "#saveLoadPanel"; 13 | const DEFAULT_PARAMETER_DISPLAY_SELECTOR = "#ccParameterDisplay"; 14 | 15 | const STANDARD_CC_CONTROL_SELECTORS = [MIDI_CC_PARAM_CLASS_SELECTOR, MIDI_CC_TOTAL_CLASS_SELECTOR]; 16 | 17 | const VALUE_DISPLAY_TIMEOUT_MS = 1000; 18 | //const paramThrottleTimerMs = 30; //if we need it 19 | 20 | class Ccynthmata { 21 | constructor(options){ 22 | options = options || {}; 23 | // TODO All The Options 24 | this._interfaceRoot = options.interfaceRoot || document; 25 | this._setupPanelElement = options.setupPanelElement 26 | ? options.setupPanelElement 27 | : this._interfaceRoot.querySelector(DEFAULT_SETUP_PANEL_SELECTOR); 28 | this._saveLoadPanelElement = options.saveLoadPanelElement 29 | ? options.saveLoadPanelElement 30 | : this._interfaceRoot.querySelector(DEFAULT_SAVELOAD_PANEL_SELECTOR); 31 | this._parameterDisplayElement = options.parameterDisplayElement 32 | ? options.parameterDisplayElement 33 | : this._interfaceRoot.querySelector(DEFAULT_PARAMETER_DISPLAY_SELECTOR); 34 | this._autoHideParameterDisplay = options.autoHideParameterDisplay || false; 35 | this._parameterDisplayTransforms = options.parameterDisplayTransforms 36 | ? options.parameterDisplayTransforms 37 | : {}; 38 | this._initPatch = options.initPatch 39 | ? options.initPatch 40 | : null; 41 | 42 | 43 | this.midi = null; // global MIDIAccess object 44 | this.midiOutPorts = null; 45 | this.selectedMidiPort = null; 46 | this.selectedMidiChannel = null; 47 | this._ccControlSelectors = Array(...STANDARD_CC_CONTROL_SELECTORS); 48 | this._valueDisplayTimeout = null; 49 | } 50 | 51 | init(){ 52 | // Get MIDI and kick everything else off 53 | navigator.requestMIDIAccess({ sysex: true }) 54 | .then( 55 | midiAccess => { 56 | console.log("MIDI ready!"); 57 | this.midi = midiAccess 58 | this.midiOutPorts = new Array(...midiAccess.outputs.values()); 59 | if(this.midiOutPorts.length < 1){ 60 | this._onMIDIFailure("No midi ports found"); 61 | } 62 | this._setupParameterControls(); 63 | this._buildSetupPanel(); 64 | this._buildSaveLoadSharePanel(); 65 | let sharedLink = this.loadSharablePatchLink(); 66 | if(!sharedLink && this._initPatch){ 67 | this.applyPatch(this._initPatch); 68 | } 69 | }, this._onMIDIFailure 70 | ) 71 | } 72 | 73 | _onMIDIFailure(msg) { 74 | alert("Could not get MIDI access.\nPlease note that MIDI in the browser currently only works in Chrome and Opera.\nIf you declined MIDI access when prompted, please refresh the page.") 75 | console.log("Failed to get MIDI access - " + msg); 76 | } 77 | 78 | _buildSetupPanel() { 79 | // Port selection. 80 | let former = document.createElement("form"); 81 | former.id = "midiSetupForm" 82 | 83 | let portSelectLabel = document.createElement("label"); 84 | portSelectLabel.textContent = "Select MIDI Device"; 85 | 86 | let portSelecter = document.createElement("select"); 87 | portSelecter.id = "portSelector"; 88 | portSelecter.onchange = (event) => { 89 | this.selectedMidiPort = this.midiOutPorts[event.target.value]; 90 | console.log(this.selectedMidiPort); 91 | this.sendCurrentPatch(); 92 | }; 93 | 94 | portSelectLabel.appendChild(portSelecter); 95 | former.appendChild(portSelectLabel); 96 | this.midiOutPorts.forEach( 97 | function (port, idx) { 98 | let optioner = document.createElement("option"); 99 | optioner.setAttribute("label", port.name); 100 | optioner.setAttribute("value", idx); 101 | portSelecter.appendChild(optioner); 102 | }, this); 103 | this.selectedMidiPort = this.midiOutPorts[0]; 104 | 105 | // Channel selection 106 | let channelSelectLabel = document.createElement("label"); 107 | channelSelectLabel.textContent = "Select MIDI Channel"; 108 | 109 | let channelSelector = document.createElement("select"); 110 | channelSelector.id = "channelSelector"; 111 | channelSelector.onchange = (event) => { 112 | this.selectedMidiChannel = parseInt(event.target.value); 113 | console.log(this.selectedMidiChannel); 114 | this.sendCurrentPatch(); 115 | }; 116 | channelSelectLabel.appendChild(channelSelector); 117 | former.appendChild(channelSelectLabel); 118 | for (let i = 0; i < 16; i++) { 119 | let optioner = document.createElement("option"); 120 | optioner.setAttribute("label", i + 1); 121 | optioner.setAttribute("value", i); 122 | channelSelector.appendChild(optioner); 123 | } 124 | this.selectedMidiChannel = 0; 125 | 126 | this._setupPanelElement.appendChild(former); 127 | } 128 | 129 | _buildSaveLoadSharePanel() { 130 | // let loadInput = document.createElement("input"); 131 | // loadInput.setAttribute("type", "file"); 132 | // loadInput.id = "sysexFileChooser" 133 | // loadInput.onchange = checkSysexFileLoad; 134 | // this._saveLoadPanelElement.appendChild(loadInput); 135 | 136 | // let loadButton = document.createElement("button"); 137 | // loadButton.id = "sysexLoadButton"; 138 | // loadButton.textContent = "Load Sysex"; 139 | // loadButton.setAttribute("disabled", true); 140 | // loadButton.onclick = tryLoadSysex; 141 | // this._saveLoadPanelElement.appendChild(loadButton); 142 | 143 | //console.log(this._initPatch); 144 | if(this._initPatch){ 145 | let initPatchButton = document.createElement("button"); 146 | initPatchButton.id = "initPatchButton"; 147 | initPatchButton.textContent = "Init Patch"; 148 | initPatchButton.onclick = () => { 149 | this.applyPatch(this._initPatch); 150 | this.sendCurrentPatch(); 151 | } 152 | this._saveLoadPanelElement.appendChild(initPatchButton); 153 | } 154 | 155 | let sendCurrentButton = document.createElement("button"); 156 | sendCurrentButton.id = "sentCurrentPatchButton"; 157 | sendCurrentButton.textContent = "Send Patch"; 158 | sendCurrentButton.onclick = (ev) => {this.sendCurrentPatch()}; 159 | 160 | let saveButton = document.createElement("button"); 161 | saveButton.id = "sysexSaveButton"; 162 | saveButton.textContent = "Save Patch"; 163 | saveButton.onclick = (ev) => {this.savePatch()}; 164 | 165 | let sharableLinkTextbox = document.createElement("textarea"); 166 | sharableLinkTextbox.id = "sharableLinkTextbox"; 167 | sharableLinkTextbox.setAttribute("readonly", true); 168 | 169 | let createSharableLinkButton = document.createElement("button"); 170 | createSharableLinkButton.id = "createSharableLinkButton"; 171 | createSharableLinkButton.textContent = "Create Sharable Patch Link"; 172 | createSharableLinkButton.onclick = () =>{ 173 | sharableLinkTextbox.value = this.makeSharablePatchLink(); 174 | } 175 | 176 | this._saveLoadPanelElement.appendChild(sendCurrentButton); 177 | this._saveLoadPanelElement.appendChild(saveButton); 178 | this._saveLoadPanelElement.appendChild(createSharableLinkButton); 179 | this._saveLoadPanelElement.appendChild(sharableLinkTextbox); 180 | } 181 | 182 | _setupParameterControls() { 183 | for(let ccControl of this.getCcElements()){ 184 | // TODO: validate the channel and cc numbers here, and remove the class from elements with invalid values 185 | if(!this.tryAttachParameterChangeHandler(ccControl)){ 186 | console.log(`Couldn't attach parameter watcher to ${ccClass} control:`) 187 | console.log(ccControl); 188 | } 189 | } 190 | } 191 | 192 | *getCcElements(){ 193 | for(let ccSelector of this._ccControlSelectors){ 194 | //for (let ccControl of this._interfaceRoot.getElementsByClassName(ccClass)) { 195 | for (let ccControl of this._interfaceRoot.querySelectorAll(ccSelector)) { 196 | yield ccControl; 197 | } 198 | } 199 | } 200 | 201 | tryAttachParameterChangeHandler(ccControl){ 202 | // TODO: work down full list of useful change events 203 | ccControl.addEventListener("mouseover", ev => this._doParameterDisplay(ev)); 204 | if("oninput" in ccControl){ 205 | //ccControl.oninput = ev => this._handleValueChange(ev); 206 | ccControl.addEventListener("input", ev => this._handleValueChange(ev)); 207 | ccControl.addEventListener("input", ev => this._doParameterDisplay(ev)); 208 | return true; 209 | }else if("onchange" in ccControl){ 210 | //ccControl.onchange = ev => this._handleValueChange(ev); 211 | ccControl.addEventListener("change", ev => this._handleValueChange(ev)); 212 | ccControl.addEventListener("change", ev => this._doParameterDisplay(ev)); 213 | return true; 214 | } 215 | return false; 216 | } 217 | 218 | getTotallerElements(ccLsb, channel){ 219 | channel = channel === undefined ? null : channel; 220 | return [...this._interfaceRoot.querySelectorAll(MIDI_CC_TOTAL_CLASS_SELECTOR)] 221 | .filter( 222 | x => parseInt(x.dataset.cclsb) === ccLsb && ((channel === null && !x.dataset.midichannel) || channel === parseInt(x.dataset.midichannel) - 1)); 223 | } 224 | 225 | getCcElementDetails(ele){ 226 | let ccLsb = "cclsb" in ele.dataset ? parseInt(ele.dataset.cclsb) : undefined; 227 | let ccMsb = "ccmsb" in ele.dataset ? parseInt(ele.dataset.ccmsb) : undefined; 228 | let overrideMidiChannel = ele.dataset.midichannel ? parseInt(ele.dataset.midichannel) - 1 : undefined; 229 | 230 | if (ele.classList.contains(MIDI_CC_PARAM_CLASS)) { 231 | let ccValue = parseInt(ele.value); 232 | 233 | return {channel: overrideMidiChannel, ccLsbNumber: ccLsb, ccMsbNumber: ccMsb, value: ccValue}; 234 | }else if (ele.classList.contains(MIDI_CC_TOTAL_CLASS)){ 235 | // we need to total everything for this change's cc number and channel then send it all at once 236 | // currently, for simplicity, this type only supports single byte cc 237 | 238 | let toTotal = this.getTotallerElements(ccLsb, overrideMidiChannel); 239 | let ccSum = toTotal.reduce((sum, x) => sum + ("checked" in x ? (x.checked ? parseInt(x.value) : 0) : parseInt(value)), 0); 240 | 241 | return {channel: overrideMidiChannel, ccLsbNumber: ccLsb, value: ccSum & 0x7f}; 242 | } 243 | else{ 244 | throw `unknown cc element class on ${ele}`; 245 | } 246 | } 247 | 248 | _doParameterDisplay(event){ 249 | if(!this._parameterDisplayElement){ 250 | return; // go away 251 | } 252 | let disFunc = this._parameterDisplayTransforms[event.target.dataset.displayvaluefunc]; 253 | let ccElementDetails = this.getCcElementDetails(event.target); 254 | if(disFunc){ 255 | this._parameterDisplayElement.innerText = disFunc(ccElementDetails.value); 256 | }else{ 257 | this._parameterDisplayElement.innerText = parseInt(ccElementDetails.value); 258 | } 259 | if(this._autoHideParameterDisplay){ 260 | this._parameterDisplayElement.style.display = "block"; 261 | if (this._valueDisplayTimeout != null) { 262 | clearTimeout(this._valueDisplayTimeout); 263 | } 264 | this._valueDisplayTimeout = setTimeout(() => { 265 | this._parameterDisplayElement.style.display = "none"; 266 | }, VALUE_DISPLAY_TIMEOUT_MS); 267 | } 268 | } 269 | 270 | _handleValueChange(event){ 271 | if (this.selectedMidiChannel != null && this.selectedMidiPort != null) { 272 | let ele = event.target; 273 | let ccElementDetails = this.getCcElementDetails(ele); 274 | this.sendCcMessage(ccElementDetails); 275 | } 276 | } 277 | 278 | sendCcMessage(ccElementDetails){ 279 | //console.log("sendCcMessage") 280 | let channel = ccElementDetails.channel === undefined ? this.selectedMidiChannel : ccElementDetails.channel; 281 | if(ccElementDetails.ccMsbNumber !== undefined){ 282 | this._sendCcMessage({channel: channel, ccNumber: ccElementDetails.ccMsbNumber, value: (ccElementDetails.value >> 7) & 0x7f}); 283 | } 284 | if(ccElementDetails.ccLsbNumber !== undefined){ 285 | this._sendCcMessage({channel: channel, ccNumber: ccElementDetails.ccLsbNumber, value: ccElementDetails.value & 0x7f}); 286 | } 287 | } 288 | 289 | buildCcMessage(ccElementDetails){ 290 | let result = []; 291 | 292 | let channel = ccElementDetails.channel === undefined ? this.selectedMidiChannel : ccElementDetails.channel; 293 | if(ccElementDetails.ccMsbNumber !== undefined){ 294 | for(let x of this._sendCcMessage({channel: channel, ccNumber: ccElementDetails.ccMsbNumber, value: (ccElementDetails.value >> 7) & 0x7f}, true)){ 295 | result.push(x); 296 | } 297 | } 298 | if(ccElementDetails.ccLsbNumber !== undefined){ 299 | for(let x of this._sendCcMessage({channel: channel, ccNumber: ccElementDetails.ccLsbNumber, value: ccElementDetails.value & 0x7f}, true)){ 300 | result.push(x); 301 | } 302 | } 303 | return result; 304 | } 305 | 306 | _sendCcMessage(options, returnOnly=false){ 307 | let paramChangeMessage = [ 308 | 0xb0 | (options.channel & 0x0f), 309 | options.ccNumber & 0x7f, 310 | options.value & 0x7f 311 | ]; 312 | console.log(paramChangeMessage); 313 | if(!returnOnly){ 314 | this.selectedMidiPort.send(paramChangeMessage); 315 | } 316 | return paramChangeMessage; 317 | } 318 | 319 | collectPatch(){ 320 | // collects all the parameters into an object; up to 17 keys: one for each channel and one for the user specified channel 321 | let patchDetails = {}; 322 | for(let ccElement of this.getCcElements()){ 323 | let ccDetails = this.getCcElementDetails(ccElement); 324 | let channel = ccDetails.channel === undefined ? 0x7f : ccDetails.channel; 325 | if(!(channel in patchDetails)){ 326 | patchDetails[channel] = {} 327 | } 328 | // if we have multiple controls colliding on channel/cc the behaviour will be to use the one latest in the DOM - this is an arbitrary decision because it's 329 | // less code to do it that way 330 | if(ccDetails.ccLsbNumber !== undefined){ 331 | 332 | // this should always be here, but I'm being slightly more cautious because messing up the serialization will make me sad. 333 | patchDetails[channel][ccDetails.ccLsbNumber] = ccDetails.value & 0x7f; 334 | } 335 | if(ccDetails.ccMsbNumber !== undefined){ 336 | patchDetails[channel][ccDetails.ccMsbNumber] = (ccDetails.value >> 7) & 0x7f; 337 | } 338 | } 339 | return patchDetails; 340 | } 341 | 342 | serializePatch(){ 343 | // Serialized format goes like this: 344 | // Header 345 | // [Channel CCs] 346 | // 347 | // Header: 348 | // ------ ------ ----------- 349 | // Offset Length Description 350 | // ------ ------ ----------- 351 | // 0 1 Serialization version (must be 0x00) 352 | 353 | // Then follows a CC structure for each channel used (will often just be the user-defined channel) 354 | // CC Structure: 355 | // ------ ------ ----------- 356 | // Offset Length Description 357 | // ------ ------ ----------- 358 | // 0 1 Channel number (0x00 = 1 - 0x0f = 16, 0x7f = user-defined) 359 | // 1 1 Parameter count - 1 (ie 0 means 1 parameter present - if no parameters, channel isn't serialized at all) (max 0x7f) 360 | // then of: 361 | // 2 + (n * 2) 1 CC number 362 | // 3 + (n * 2) 1 CC value 363 | // 364 | // The data is then packed as per packBytes() 365 | 366 | let patch = this.collectPatch(); 367 | 368 | let serializedRaw = [ 369 | 0x00, 370 | ]; 371 | 372 | for(let channel in patch){ 373 | serializedRaw.push(channel & 0x7f); 374 | let channelCcs = patch[channel] 375 | let paramCount = Object.keys(channelCcs).length; 376 | if(paramCount > 128){ 377 | throw "illegal number of CC parameters"; 378 | } 379 | serializedRaw.push(paramCount - 1); 380 | for(let ccNumber in channelCcs){ 381 | serializedRaw.push(ccNumber & 0x7f); 382 | serializedRaw.push(channelCcs[ccNumber] & 0x7f); 383 | } 384 | } 385 | 386 | return Ccynthmata.packBytes(serializedRaw); 387 | } 388 | 389 | deserializePatch(packedData){ 390 | let data = Ccynthmata.unpackBytes(packedData); 391 | console.log(data); 392 | if(data[0] !== 0){ 393 | throw "unknown patch data version"; 394 | } 395 | let patch = {}; 396 | let i = 1; 397 | while(i < data.length - 2){ 398 | let channel = data[i++]; 399 | //console.log(`Deserialize: channel is ${channel}`) 400 | if(channel != 0x7f && channel > 0x0f){ 401 | throw `invalid midi channel number ${channel}`; 402 | } 403 | let count = data[i++] + 1; 404 | //console.log(`Deserialize: count is ${count}`) 405 | patch[channel] = {}; 406 | for(let j = 0; j < count; j++){ 407 | patch[channel][data[i]] = data[i + 1]; 408 | i += 2; 409 | } 410 | } 411 | return patch; 412 | } 413 | 414 | setCcValue(ccControl, value, isMsb=false){ 415 | if(ccControl.classList.contains(MIDI_CC_PARAM_CLASS)){ 416 | let currentValue = parseInt(ccControl.value); 417 | if(isMsb){ 418 | currentValue &= 0x7F; 419 | ccControl.value = currentValue | ((value & 0x7f) << 7) 420 | }else{ 421 | currentValue &= 0x3f80; 422 | ccControl.value = currentValue | (value & 0x7f); 423 | } 424 | 425 | }else if(ccControl.classList.contains(MIDI_CC_TOTAL_CLASS)){ 426 | // Note/Known Issue: currently this will only work for checkable controls 427 | 428 | let toTotal = this.getTotallerElements( 429 | parseInt(ccControl.dataset.cclsb), parseInt(ccControl.dataset.midichannel) - 1).filter(x => "checked" in x); 430 | toTotal.sort((a, b) => parseInt(b.value) - parseInt(a.value)); 431 | 432 | let appliedNames = new Set(); // used to stop touching radiobuttons in a group once the highest applicable has been set 433 | for(let totee of toTotal){ 434 | let controlValue = parseInt(totee.value) 435 | if(value >= controlValue){ 436 | if(!totee.name || !appliedNames.has(totee.name)){ // handling radiobuttons 437 | totee.checked = true; 438 | value -= controlValue; 439 | if(totee.name){ 440 | appliedNames.add(totee.name); 441 | } 442 | } 443 | }else{ 444 | totee.checked = false; 445 | } 446 | } 447 | if(value != 0){ 448 | console.log(`Warning: didn't exhaust the total when applying to midiCcTotal controls for cc ${ccControl.dataset.cclsb}`); 449 | } 450 | } 451 | } 452 | 453 | applyPatch(patch){ 454 | // input should be in the same format as the output from collectPatch() 455 | // this is useful to know - if you wanted to plonk the result of a JSON 456 | // response from a server into the interface - this is your doorway in 457 | 458 | // we get all the controls and look them up in the patch rather than the other way around 459 | let missCount = 0; 460 | for(let ccControl of this.getCcElements()){ 461 | let details = this.getCcElementDetails(ccControl); 462 | let channel = details.channel !== undefined ? details.channel : 0x7f; 463 | if(!(channel in patch)){ 464 | console.log(`Couldn't get channel ${channel} from patch`); 465 | missCount++; 466 | continue; 467 | } 468 | if(details.ccLsbNumber !== undefined){ 469 | let lsbValue = patch[channel][details.ccLsbNumber]; 470 | if(lsbValue === undefined){ 471 | console.log(`couldn't get cc ${details.ccLsbNumber} for channel ${channel}`); 472 | missCount++; 473 | }else{ 474 | this.setCcValue(ccControl, lsbValue); 475 | } 476 | } 477 | 478 | if(details.ccMsbNumber !== undefined){ 479 | let msbValue = patch[channel][details.ccMsbNumber]; 480 | if(msbValue === undefined){ 481 | console.log(`couldn't get cc ${details.ccMsbNumber} for channel ${channel}`); 482 | missCount++; 483 | }else{ 484 | this.setCcValue(ccControl, msbValue, true); 485 | } 486 | } 487 | 488 | } 489 | console.log(`Missed ${missCount} values.`) 490 | } 491 | 492 | makeSharablePatchLink(){ 493 | let serializedPatch = this.serializePatch(); 494 | let patchAsB64 = base64js.fromByteArray(serializedPatch); 495 | let patchNameEle = this._interfaceRoot.querySelector("#ccynthmataPatchName"); 496 | let patchName = patchNameEle ? patchNameEle.value : undefined; 497 | // abusing dom to parse the current url 498 | var parser = document.createElement('a'); 499 | parser.href = window.location; 500 | let result = parser.origin + parser.pathname + "?p=" + encodeURIComponent(patchAsB64); 501 | if(patchName !== undefined){ 502 | result += "&n=" + encodeURIComponent(patchName); 503 | } 504 | return result; 505 | } 506 | 507 | loadSharablePatchLink(){ 508 | // abusing dom to parse the current url 509 | var parser = document.createElement('a'); 510 | parser.href = window.location; 511 | let queryString = parser.search; 512 | var searchParams = new URLSearchParams(queryString); 513 | if(!searchParams.has("p")){ 514 | return false; 515 | } 516 | let patchAsB64 = searchParams.get("p"); 517 | let patchRaw = base64js.toByteArray(patchAsB64); 518 | let patch = this.deserializePatch(patchRaw); 519 | this.applyPatch(patch); 520 | let patchNameEle = this._interfaceRoot.querySelector("#ccynthmataPatchName"); 521 | if(searchParams.has("n") && patchNameEle !== undefined){ 522 | patchNameEle.value = searchParams.get("n"); 523 | } 524 | return true; 525 | } 526 | 527 | sendCurrentPatch(){ 528 | for(let ccControl of this.getCcElements()){ 529 | // TODO: if something is going to need throttling, it's this. Need to investigate and test. 530 | let details = this.getCcElementDetails(ccControl); 531 | this.sendCcMessage(details); 532 | } 533 | } 534 | 535 | savePatch(){ 536 | let fullDump = []; 537 | for(let ccControl of this.getCcElements()){ 538 | // TODO: if something is going to need throttling, it's this. Need to investigate and test. 539 | let details = this.getCcElementDetails(ccControl); 540 | for(let x of this.buildCcMessage(details)){ 541 | fullDump.push(x); 542 | } 543 | } 544 | 545 | let buffer = new Uint8ClampedArray(new ArrayBuffer(fullDump.length)); 546 | for (let i = 0; i < fullDump.length; i++) { 547 | buffer[i] = fullDump[i]; 548 | } 549 | 550 | var file = new Blob([buffer], { type: "application/octet-binary" }); 551 | let a = document.createElement("a"); 552 | let url = URL.createObjectURL(file); 553 | a.href = url; 554 | a.download = `patch_${new Date().toISOString()}.midi`; 555 | document.body.appendChild(a); 556 | a.click(); 557 | setTimeout(function () { 558 | document.body.removeChild(a); 559 | window.URL.revokeObjectURL(url); 560 | }, 0); 561 | 562 | return fullDump; 563 | } 564 | 565 | static packBytes(unpackedBytes){ 566 | // This seems like overkill, but as we have to go to Base64 and incur a 4/3 size increase and 567 | // there are likely limits on query-string length on some server configurations, this gets us 568 | // a little more space to work with with a 7/8 reduction. 569 | // TODO: write up some details around likely limits on size/number of parameters 570 | if (unpackedBytes.length === 0){ 571 | return []; 572 | } 573 | let result = []; 574 | let current = 0; 575 | let prev = 0; 576 | let shift; 577 | for(let i = 0; i < unpackedBytes.length; i++){ 578 | if(unpackedBytes[i] > 127){ 579 | throw "Cannot pack values over 127 (0x7f)"; 580 | } 581 | shift = i % 8; 582 | if(shift == 0){ 583 | prev = unpackedBytes[i] & 0x7f; 584 | continue; 585 | } 586 | current = (unpackedBytes[i] << (8 -shift)) & 0xff 587 | result.push(current | prev); 588 | prev = unpackedBytes[i] >> shift 589 | 590 | } 591 | result.push(prev); 592 | 593 | return result; 594 | 595 | } 596 | 597 | static unpackBytes(packedBytes){ 598 | let result = []; 599 | let last = 0; 600 | let current = 0; 601 | for(let i = 0; i < packedBytes.length; i++){ 602 | let shift = i % 7; 603 | let lowerMask = 0x7f >> shift; 604 | let upperMask = (0xff80 >> shift) & 0xff; 605 | 606 | if(i > 0 && shift == 0){ 607 | result.push(last) 608 | last = 0; 609 | } 610 | current = (packedBytes[i] & lowerMask) << shift; 611 | 612 | result.push(current | last); 613 | 614 | last = (packedBytes[i] & upperMask) >> (7 - shift); 615 | } 616 | return result; 617 | } 618 | } 619 | -------------------------------------------------------------------------------- /example.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | ccynthmata: example 6 | 7 | 11 | 12 | 13 | 14 | 15 |

Volca Drum Patch Editor

16 |
17 |

Midi Device Setup

18 | 19 |
20 |
21 |

Save/Load/Export/Share

22 | 23 |
24 |
25 | 26 | 27 |
28 |
29 |

Part 1

30 |
31 | 32 |
33 |

Layer 1

34 |
35 |
36 |

Voice

37 | 38 | 42 | 43 | 47 | 48 | 52 | 53 | 57 | 58 | 62 |
63 |
64 | 65 | 69 | 70 | 74 | 75 | 79 |
80 |
81 | 82 | 86 | 87 | 91 | 92 | 96 |
97 |
98 | 99 | 103 |
104 |
105 | 106 | 110 |
111 |
112 | 113 | 117 |
118 |
119 | 120 | 124 |
125 |
126 | 127 | 131 |
132 |
133 | 134 | 138 |
139 | 140 |
141 |
142 |
143 | 144 |
145 |
146 |

Layer 2

147 |
148 |
149 |

Voice

150 | 151 | 155 | 156 | 160 | 161 | 165 | 166 | 170 | 171 | 175 |
176 |
177 | 178 | 182 | 183 | 187 | 188 | 192 |
193 |
194 | 195 | 199 | 200 | 204 | 205 | 209 |
210 |
211 | 212 | 216 |
217 |
218 | 219 | 223 |
224 |
225 | 226 | 230 |
231 |
232 | 233 | 237 |
238 |
239 | 240 | 244 |
245 |
246 | 247 | 251 |
252 | 253 |
254 |
255 |
256 |
257 | 258 | 259 | 260 | --------------------------------------------------------------------------------