├── .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 |