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
HTML
<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>