UNPKG

node-red-contrib-music

Version:

Synthesise music with node-red. The beat node creates regular beats at a rate you can control, and which can be sunchronised with other machines. The divider node adds information to beat events, dividing beats into bars, bars into phrases, phrases into s

502 lines (401 loc) 21.6 kB
<script type="text/javascript"> const synthtypesURL = "node-red-contrib-music/synthtypes"; const userSampleURL = 'node-red-contrib-music/usersamples'; // synthtypes is an object with all the associated data, synthtypeList just the names (keys of synthtypes) let synthtypes, tagSet; // this is only used to order the tags we have. Any tags not in this list will not be shown // pairs are exclusive // possibly should go in server const sortTags = [['tuned', 'untuned'], ['short', 'long'], ['bass', 'snare'], ['drum', 'cymbal', 'perc'], 'misc', ['sonic-pi', 'tidal']]; tagSet = new Set(); // make a set of tags and also an object recording mutually exclusive combinations let exclusiveTags = {}; for (let element of sortTags) { if (Array.isArray(element)) { for (let tag of element) { exclusiveTags[tag] = element.filter(y => (y !== tag)); tagSet.add(tag); } } else { tagSet.add(element); } } // do this dynamically from the server so that if we change the list of synths we do not have to re-do all the synths in the examples $.getJSON(synthtypesURL, function (data) { synthtypes = data; // in case there are any we have missed ... // these tags come from the .tuned property rather than the .tags property tagSet.add('tuned'); tagSet.add('untuned'); for (let synthtype of Object.keys(synthtypes)) { let tags = synthtypes[synthtype].tags; if (Array.isArray(tags)) { for (let tag of tags) { tagSet.add(tag); } } } }); RED.nodes.registerType('synth', { category: 'music', color: '#a6bbcf', defaults: { name: { value: "", required: false }, synthtype: { value: "kick", required: true }, filterTags: { value: new Set(), required: true }, volume: { value: 50 }, octave: { value: 0 }, root: { value: "", required: false }, scale: { value: "", required: false }, degree: { value: "", required: false }, outBus: { value: 0, required: true }, synthcontrols: { value: {} }, sampleName: { value: "", required: false } }, inputs: 1, inputLabels: "tick,synthcontrol:", outputs: 1, outputLabels: ["synthesis instructions"], icon: "music.png", label: function () { if (this.synthtype === "user-sample") { return this.sampleName || this.synthtype; } else { return this.name || this.synthtype || "synth"; } }, oneditprepare: function () { node = this; function setSynthDescription() { let synthdescription = synthtypes[node.synthtype].description || " "; $('#node-input-synthtype-description').html(synthdescription); } function populateSynthcontrols() { let synthcontrolSpecs = synthtypes[node.synthtype].synthcontrols || {}; // all synths allow pan at least synthcontrolSpecs.pan = { "description": "position in stereo field: left (-1) or right (1)", "min": -1, "max": 1, "default": 0 }; const container = $('#node-input-synthcontrol-container'); for (let synthcontrol in synthcontrolSpecs) { let row = $('<div/>', { class: 'form-row' }).appendTo(container); let id = 'node-input-synthcontrol-' + synthcontrol; let synthdetails = synthcontrolSpecs[synthcontrol] || {}; let max = synthdetails.max || 1; let min = synthdetails.min || 0; let default_ = synthdetails.default; let description = synthdetails.description ? (synthdetails.description + '. ') : ''; let info = `${description}min: ${min}, max: ${max}, default: ${default_}`; let value = node.synthcontrols[synthcontrol] || default_; let labelElement = $(`<label>${synthcontrol}</label>`, { 'for': id }).appendTo(row); let step = Math.pow(10, Math.floor(Math.log10(max - min)) - 2); let valueField = $('<input/>', { id: id, class: "node-input-synthcontrol", width: "7em", type: "number", min: min, max: max, step: step }).appendTo(row); valueField.val(value); let infoElement = $(`<div><small>${info}</small></div>`, { style: 'float:right' }).appendTo(row); noControls = false; } // add other options depending on the synthtype, to be specified in synthttypes.json } function populateSynthtypeSelect() { let synthtypeList = []; for (let synthtype in synthtypes) { synthtypeList.push(synthtype); } synthtypeList.sort(); const select = $("#node-input-synthtype"); select.empty(); for (let synthtype of node.synthtypeList) { let row = $("<option value='" + synthtype + "'>" + synthtype + "</option>"); if (node.synthtype === synthtype || node.synthtypeList.length === 1) { row.attr("selected", "selected"); } select.append(row); } } function populateUserSampleSelect() { const selectSample = $('#node-input-sampleName'); selectSample.empty(); $.getJSON(userSampleURL, function (data) { for (let sample of data) { let row = $(`<option value='${sample}'>${sample}</option>`); if (node.userSample === sample || data.length === 1) { row.attr("selected", "selected"); } selectSample.append(row); } }); } function filterSynthtypes() { $('#node-input-synthtype-filter-container span').css("font-weight", "normal"); for (let tag of node.filterTags) { $('#node-input-synthtype-filter-' + tag).css("font-weight", "bold"); } if (node.filterTags.size === 0) { $('#node-input-synthtype-filter-all').css("font-weight", "bold"); } node.synthtypeList = []; for (let synthtype in synthtypes) { let includeSynthtype = true; for (let tag of node.filterTags) { if (tag === 'tuned') { includeSynthtype = includeSynthtype && synthtypes[synthtype].tuned; } else if (tag === 'untuned') { includeSynthtype = includeSynthtype && !synthtypes[synthtype].tuned; } else { let tags = synthtypes[synthtype].tags; if (!Array.isArray(tags) || !tags.includes(tag)) { includeSynthtype = false; } } } if (includeSynthtype) { node.synthtypeList.push(synthtype); } } node.synthtypeList.sort(); populateSynthtypeSelect(); }; // for some reason node.filterTags is being initialised as {} which is causing problems if (!node.filterTags.size) { node.filterTags = new Set(); } filterSynthtypes(); populateSynthcontrols(); const tunedContainer = $('#node-input-tuned-container'); const sampleContainer = $('#node-input-userSample-container'); $('#node-input-synthtype').change( function () { const option = $(this).find('option:selected').val(); if (synthtypes[option] && synthtypes[option].tuned) { tunedContainer.show(); } else { tunedContainer.hide(); } if (option === 'user-sample') { sampleContainer.show(); $('#node-input-sampleName').empty(); populateUserSampleSelect(); } else { sampleContainer.hide(); } if (option !== node.synthtype) { node.synthtype = option; setSynthDescription(); node.synthcontrols = {}; $('#node-input-synthcontrol-container').empty(); populateSynthcontrols(); } } ); $('#node-input-sampleName').change( function () { const option = $(this).find('option:selected').val(); if (option) { node.userSample = option; } } ); $('#node-input-synthtype-filter-all').click( () => { node.filterTags = new Set(); filterSynthtypes(); } ); $('#add-sample').click( () => { const addSampleButton = $('#add-sample'); addSampleButton.hide(); let uploadSampleContainer = $('#upload-sample-container'); uploadSampleContainer.empty(); uploadSampleContainer.append(` <label for= 'upload-sample-file'>Select new sample</label> <input type='file' id='upload-sample-file' accept='audio/wav, audio/x-aiff'> `); $('#upload-sample-file').change( function() { let file = this.files[0]; const fd = new FormData(); fd.append('sample', file); $.ajax({ type: "POST", enctype: 'multipart/form-data', processData: false, contentType: false, cache: false, data: fd, url: userSampleURL, success: function (data) { uploadSampleContainer.empty(); node.userSample = file.name; populateUserSampleSelect(); addSampleButton.show(); }, error: function (e) { console.log("ERROR : ", e); uploadSampleContainer.empty(); addSampleButton.show(); } } ); } ); }); // set the focus on the synth selector to allow quick selection by tabbing // but node-red sets focus after saving, so delay a little setTimeout(() => { $("#node-input-synthtype").blur(); $("#node-input-synthtype").focus() }, 200); for (let tag of tagSet) { $('#node-input-synthtype-filter-container').append( `<span id="node-input-synthtype-filter-${tag}"> | ${tag}</span>` ); $('#node-input-synthtype-filter-' + tag).click( () => { if (node.filterTags.has(tag)) { node.filterTags.delete(tag); } else { node.filterTags.add(tag); let exclusive = exclusiveTags[tag]; if (Array.isArray(exclusive)) { for (let toDelete of exclusive) { node.filterTags.delete(toDelete); } } } if (node.filterTags.has('tuned')) { tunedContainer.show(); } filterSynthtypes(); } ); } filterSynthtypes(); }, oneditsave: async function () { let synthcontrols = $('.node-input-synthcontrol'); synthcontrols.each(function () { const control_id = $(this).attr('id'); const control = control_id.split('-').pop(); const value = $(this).val(); node.synthcontrols[control] = value; } ); } }); </script> <script type="text/x-red" data-template-name="synth"> <div class="form-row"> <label for="node-input-synthtype"> Synth type</label> <select id="node-input-synthtype"> </select> <div id="node-input-synthtype-filter-container" style="float:right;font-size:0.8em;max-width:25%"> <span id="node-input-synthtype-filter-all">all</span> </div> </div> <div class="form-row"> <label for="node-input-volume"> Volume</label> <input type="number" min="0" max="100" id="node-input-volume" placeholder="0-100" style="width:7em"> </div> <div id='node-input-userSample-container' style="display:none"> <div class="form-row"> <label for="node-input-sampleName"> User sample</label> <select id="node-input-sampleName"></select> </div> <div class="form-row"> <button id="add-sample">Add a new sample</button> <div id="upload-sample-container"></div> </div> </div> <div id='node-input-tuned-container' style="display:none"> <div class="form-row"> <label for="node-input-octave"> Octave shift (+/-)</label> <input type="number" min="-4" max="4" id="node-input-octave" placeholder="e.g. -1" style="width:7em"> </div> <div id="node-input-key-container"> <div class="form-row"> <label for="node-input-root"> Scale</label> <input type="text" id="node-input-root" placeholder="e.g. C4" style="width:7em"> <select id="node-input-scale"> <option></option> <option>minor</option> <option>major</option> <option>dorian</option> <option>mixolydian</option> <option>major pentatonic</option> <option>minor pentatonic</option> <option>blues</option> <option>chromatic</option> <option>whole tone</option> <option>octatonic</option> </select> </div> <div class="form-row"> <label for="node-input-degree"> Degree</label> <select id="node-input-degree"> <option></option> <option>I</option> <option>II</option> <option>III</option> <option>IV</option> <option>V</option> <option>VI</option> <option>VII</option> </select> </div> </div> </div> <div id="node-input-synthtype-description"></div> <div class="form-row" width="25em"><b>Synth controls (synthcontrol)</b></div> <div id="node-input-synthcontrol-container"></div> <div class="form-row"> <label for="node-input-name"><i class="icon-tag"></i> Name</label> <input type="text" id="node-input-name" placeholder=""> </div> </script> <script type="text/x-red" data-help-name="synth"> <p>Turns note instructions messages into messages for controlling synthesisers.</p> <h3>Inputs</h3> <p>A synth usually gets its input from a <code>sequencer</code> node or a <code>beat</code> node, possibly via <code>divider</code>, <code>link</code> and <code>switch</code> nodes.</p> <dl class='message-properties'> <dt class="optional">topic <span class="property-type">string</span></dt> <dd><p>If <code>msg.topic</code> is <code>volume</code> then <code>msg.payload</code> is used to set the volume of the sounds produced, in the range 0 to 100.</p> <p>If <code>msg.topic</code> starts with <code>synthcontrol:</code> then the rest of the <code>msg.topic</code> is interpreted as a synth parameter name, and the <code>msg.payload</code> is used as its value. The parameters (synthcontrol) that can be changed vary from synth to synth, their details can be seen in the configuration.</p></dd> <dt class="optional">note <span class="property-type">number|array</span></dt> <dd>If <code>msg.note</code> is defined then it is used to set the <code>midi</code> note value of the output. If <code>msg.note</code> is an array then all the notes in the array are played at the same time to form a chord.</dd> <dt>payload <span class='property-type'>string|number</span></dt> <dd>When <code>msg.topic</code> is empty<ul> <li>If <code>msg.payload</code> is <code>play</code> or <code>tick</code> then instructions are output for playing a note.</li> <li>If <code>msg.payload</code> is <code>reset</code> then the synth goes back to its starting state.</li> <li>If <code>msg.payload</code> is <code>stop</code> then the synth is stopped. This only makes a difference is the synth has sustain</li> </ul></dd> <dt><span class='optional'>timeTag</span> <span class='property-type'>number</span></dt> <dd>If a <code>timeTag</code> property is given for the incoming <code>play</code> message it is included in the outgoing message.</dd> </dl> <p>If an input message has the topic synthcontrol:<i>control_name</i> then it will set the synth control to the value contained in the payload of the message</p> <h3>Outputs</h3> <p>A synth usually sends its output to a <code>supercollider</code> node, possibly via one or more <code>soundfx</code> nodes.</p> <h3>Configuration</h3> <dl class='message-properties'> <dt><span class='optional'>Synth type</span> <span class='property-type'>string</span></dt> <dd>The name of the synthesiser type to use, selected from the pull-down list. This list can be filtered by clicking on the filters (e.g. <i>untuned</i>) on the right hand side of the selection list. If the 'user-sample' option is selected then any .wav or .aif files in the uploads folder are available. A web interface for uploading samples is also provided. If a provided sample file name (before the .wav or .aif extension) ends with an integer then this is interpreted as the pitch of the sample as a midi value, and is used as the basis of pitch variation through the 'note' property.</dd> <dt>Volume <span class='property-type'>number</span></dt> <dd>A value in the range 0 to 100 to specify the volume that the synth uses after deployment or reset.</dd> <dt>Octave shift<span class='property-type'>number</span></dt> <dd>A number, usually in the range -3 to +3, to shift the pitch relative to the root note, with negative numbers used to lower the pitch.</dd> <dt>Scale <span class='property-type'>string</span></dt> <dd>If defined, this gives the name and octave of the start of the scale, which corresponds to a <code>msg.note</code> of 1. If not defined, the global or default value is used.</dd> <dt>scale type <span class='property-type'>string</span></dt> <dd>The midi pitch value that is output depends on the value of <code>msg.note</code> and the scale root and the scale type. If not defined, the global or default value is used.</dd> <dt>Degree <span class='property-type'>string</span></dt> <dd>If defined, this selects the scale relative to the global key. If the global key is C major, then degree II is D minor and degree V is G major.</dd> <dt><span class='optional'>Synth controls (synthcontrol)</span> <span class='property-type'>number</span></dt> <dd>Each synth type has has its own set of parameters that control its operation.</dd> <dt><span class='optional'>Name</span> <span class='property-type'>string</span></dt> <dd>The display name for the node: defaults to the synthesiser type in use.</dd> </dl> <h3>Details</h3> <p>The synth node relies on there being a sound server (e.g. SuperCollider) running, to which the messages are sent through a <code>supercollider</code> node. </p> <p>Separate sound effects (soundfx) nodes can be defined which accept the output of a synth node.</p> </script>