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
524 lines (447 loc) • 19.4 kB
JavaScript
module.exports = function (RED) {
'use strict';
const mathjs = require('mathjs');
const _ = require('underscore');
const WebSocket = require('ws');
const wsPort = 2880; // seems to be unused and is reminiscent of node-red port 1880
const wsPath = 'beat';
const heartbeatInterval = 5000;
const minBPM = 10;
const maxBPM = 1000;
const defaultBPM = 200;
const tapsToStart = 4;
function BeatNode (config) {
RED.nodes.createNode(this, config);
var node = this;
if (!config.autostart) {
stopBeat();
}
reset();
this.on('input', function (msg) {
switch (msg.topic) {
case 'bpm':
node.bpm = msg.payload;
setBPM();
break;
default:
switch (msg.payload) {
case 'start':
if (!node.started && node.sharing !== 'follower') {
beat();
node.started = true;
}
node.send(msg);
break;
case 'stop':
stopBeat();
node.send(msg);
break;
case 'reset':
stopBeat();
reset();
node.send(msg);
break;
// tap is used to set the tempo and autostart
case 'tap':
tap();
break;
case 'tick':
tick();
break;
default:
node.send(msg);
}
}
});
this.on('close', function () {
if (node.started) {
clearTimeout(node.tick);
}
if (node.heartbeat) {
clearTimeout(node.heartbeat);
node.heartbeat = null;
}
if (node.wss) {
node.wss.close();
}
if (node.ws) {
node.ws.close();
}
});
function reset () {
node.started = node.started || false;
clearTimeout(node.tick);
clearTimeout(node.heartbeat);
node.started = false;
node.beatNum = 0;
node.output = config.output;
node.subBeats = config.subBeats || [];
node.latency = Number(config.latency) || 0;
node.sharing = config.sharing || 'standalone';
node.conductorIP = config.conductorIP || '127.0.0.1';
node.autostart = config.autostart;
// get rid of old sockets if already there
if (node.wss) {
node.wss.close();
}
if (node.ws) {
node.ws.close();
}
switch (node.sharing) {
case 'conductor':
resetConductor();
break;
case 'follower':
setTimeout(resetFollower, 100);
break;
default:
// do nothing
}
setFractionalBeats(node.subBeats);
setBPM();
if (node.autostart) {
if (!node.started) {
node.started = true;
beat();
}
}
}
function stopBeat () {
clearTimeout(node.tick);
node.thisBeatStart = null;
node.nextBeatStart = null;
node.beatCounter = {};
node.started = false;
if (node.wss) {
node.wss.clients.forEach(function each (client) {
if (client.readyState === WebSocket.OPEN) {
client.send(JSON.stringify({ payload: 'stop' }));
}
});
}
}
function getBPM () {
return node.bpm || config.bpm || node.context().flow.get('bpm') || node.context().global.get('bpm') || defaultBPM;
}
function setBPM () {
if (getBPM() === node.current_bpm) {
return;
}
var bpm = Number(getBPM());
if (!isNaN(bpm)) {
if (bpm > minBPM && bpm < maxBPM) {
node.interval = 60000.0 / bpm;
node.fractionalIntervals = _.map(
node.fractionalBeats,
function (event) { return { names: event.names, pos: event.pos * node.interval }; });
node.current_bpm = bpm;
} else {
node.warn(`BPM not in range ${minBPM}-${maxBPM}`);
}
} else {
node.warn('BPM is not a number: ' + bpm);
}
}
function setFractionalBeats (subBeats) {
node.subBeatCounts = {};
if (subBeats.length > 0) {
// find the LCM of the subBeat counts
var lcm = 1;
var subBeatList = [];
for (let i = 0; i < subBeats.length; i++) {
let subBeat = subBeats[i];
var count = Number(subBeat.count);
if (count > 0 && Number.isInteger(count)) {
lcm = mathjs.lcm(lcm, count);
subBeatList.push({ name: subBeat.name, count: count });
} else {
node.warn('subBeat count for ' + subBeat.name + ' needs to be a positive integer: ' + count);
return;
}
}
// generate a list of lists of all the subBeats with their position in the list
var allEvents = [{ name: node.output, pos: 0 }];
for (let i = 0; i < subBeatList.length; i++) {
let subBeat = subBeatList[i];
node.subBeatCounts[subBeat.name] = subBeat.count;
for (var j = 0; j < subBeat.count; j++) {
allEvents.push({ name: subBeat.name,
pos: j * lcm / subBeat.count });
}
}
allEvents.sort(function (a, b) { return a.pos - b.pos; });
var combinedEvents = _.reduce(allEvents, function (sofar, event) {
if (sofar.length === 0) {
return [{ names: [event.name], pos: event.pos }];
}
var lastSofar = _.last(sofar);
var lastPos = lastSofar.pos;
var lastNames = lastSofar.names;
var eventPos = event.pos;
if (lastPos === eventPos) {
lastNames.push(event.name);
sofar.pop();
sofar.push({ names: lastNames, pos: lastPos });
} else {
sofar.push({ names: [event.name], pos: eventPos });
}
return sofar;
}, []);
node.fractionalBeats = _.map(combinedEvents, function (event) { event.pos /= lcm; return event; });
} else {
node.fractionalBeats = [{ names: [node.output], pos: 0 }];
}
node.subBeatEnds = _.map(node.fractionalBeats, x => x.names).reverse();
node.allSubBeatNames = node.fractionalBeats[0].names;
node.beatCounter = {};
node.subBeatNum = 0;
node.thisBeatStart = null;
node.nextBeatStart = null;
}
function resetConductor () {
// set up web socket server
// get rid of old one if already there
node.wss = new WebSocket.Server({
port: wsPort,
perMessageDeflate: false,
clientTracking: true
}, () => node.log('Web socket server created for beat conductor'));
node.wss.on('connection', function connection (ws, req) {
ws.on('message', function incoming (msg) {
var rcvd = Date.now();
var jmsg = JSON.parse(msg);
if (jmsg.payload && jmsg.payload === 'sync') {
jmsg.conductorSent = rcvd;
}
ws.send(JSON.stringify(jmsg));
});
});
// when a beat happens,
// * send it to all followers
// * wait for response for updating offset estimate
// can have more than one follower per machine?
// for offsets keep 20 most recent
// replace randomly to allow for change in offset
// use minumum offset, as they seem to be the most accurate
// make wieghted changes to offset
}
function updateOffsets (msg, fRcvd) {
const fSent = msg.followerSent;
const cSent = msg.conductorSent;
const rtt = fRcvd - fSent;
const offset = cSent - fSent - rtt / 2;
const ol = node.offsets.length;
if (ol < 20) {
node.offsets.push(offset);
} else {
node.offsets[_.random(ol - 1)] = offset;
}
// then take the minimum
const minOffset = Math.min(...node.offsets);
// update weighted according to how many we have collected: starts fast
if (ol > 1) {
node.offset = (minOffset + (ol - 1) * node.offset) / ol;
} else {
node.offset = minOffset;
}
}
function resetFollower () {
// set up web socket connection
node.connected = false;
node.offsets = [];
const wsURL = 'ws://' + node.conductorIP + ':' + wsPort + '/' + wsPath;
try {
node.ws = new WebSocket(wsURL);
node.connected = true;
} catch (e) {
node.warn('Cannot open connection at ' + wsURL);
node.connected = false;
return;
}
node.ws.on('error', function error () {
node.warn('Cannot open connection at ' + wsURL);
node.connected = false;
});
node.ws.on('open', function open () {
// launch heartbeat a little while
// and make it random so that not all followers deployed at the same time
// have their heartbeat at the same time
setTimeout(heartbeat, 1000 * Math.random());
});
node.ws.on('message', function incoming (msg) {
const rcvd = Date.now();
const jmsg = JSON.parse(msg);
switch (jmsg.payload) {
case 'tick':
const localThisBeatStart = Number(jmsg.thisBeatStart) + Number(node.offset);
const localNextBeatStart = Number(jmsg.nextBeatStart) + Number(node.offset);
const incomingBeat = Number(jmsg.beat);
let beatCount = node.beatCounter['beat'];
/*
console.log(`localThisBeatStart ${localThisBeatStart} remotethisbeatstart ${jmsg.thisBeatStart} incomingBeat ${incomingBeat} beatCount ${beatCount} node.thisBeatStart ${node.thisBeatStart} node.nextBeatStart ${node.nextBeatStart}`);
*/
// update the bpm if necessary
node.bpm = jmsg.bpm;
if (node.started) {
if (incomingBeat === beatCount + 1) {
// beat is on time (conductor is ahead of follower)
node.nextBeatStart = localThisBeatStart;
} else if (incomingBeat === beatCount) {
// beat is slightly late (follower ahead of conductor)
node.nextBeatStart = localNextBeatStart;
} else if (incomingBeat < beatCount) {
// more than one beat late (follower ahead of conductor)
node.nextBeatStart = localNextBeatStart + node.interval;
} else {
// more than one beat early (conductor ahead of follower)
// should maybe set next beatStart to now
// or just reset the follower beatNum if a long way out
node.nextBeatStart = localThisBeatStart;
beat();
}
} else {
node.beatCounter = {};
node.beatCounter['beat'] = jmsg.beat - 1;
node.nextBeatStart = localThisBeatStart;
beat();
node.started = true;
}
break;
case 'stop':
stopBeat();
break;
case 'sync':
updateOffsets(jmsg, rcvd);
break;
default:
node.warn('unrecognised message in websocket client ' + msg);
}
});
function heartbeat () {
if (node.connected) {
let now = Date.now();
let msg = { payload: 'sync',
followerSent: now };
node.ws.send(JSON.stringify(msg), function ack (error) {
if (error) {
node.warn('heartbeat sync error ' + error);
node.connected = false;
}
});
} else {
resetFollower();
}
node.heartbeat = setTimeout(heartbeat, heartbeatInterval);
}
// register with server
// * wait for response with time
// * send another response for updating latency/offset estimate
// wait for beat if it doesn't arrive, do it anyway and wait for the next one
// add sub-beats locally
}
function beat () {
tick();
var nextSubBeat = node.fractionalIntervals[node.subBeatNum];
var nextSubBeatStart = node.thisBeatStart + nextSubBeat.pos;
var interval = Number(nextSubBeatStart) - Number(Date.now()) - Number(node.latency);
node.tick = setTimeout(beat, Math.max(interval, 0));
}
function tick () {
node.beatCounter = node.beatCounter || {};
node.subBeatNum = node.subBeatNum || 0;
node.thisBeatStart = node.thisBeatStart || Date.now();
if (node.subBeatNum === 0) {
setBPM();
}
// node.interval is set in setBPM()
node.nextBeatStart = Math.round(node.nextBeatStart || node.thisBeatStart + node.interval);
var subBeat = node.fractionalIntervals[node.subBeatNum];
for (let i = 0; i < subBeat.names.length; i++) {
let subName = subBeat.names[i];
node.beatCounter[subName] = node.beatCounter[subName] || 0;
node.beatCounter[subName]++;
}
if (node.sharing === 'conductor' && node.subBeatNum === 0) {
const bmsg = { payload: 'tick',
start: ['beat'],
end: ['beat'],
beat: node.beatCounter['beat'],
bpm: node.current_bpm,
thisBeatStart: node.nextBeatStart,
nextBeatStart: node.nextBeatStart + node.interval
};
const jbmsg = JSON.stringify(bmsg);
node.wss.clients.forEach(function each (client) {
if (client.readyState === WebSocket.OPEN) {
client.send(jbmsg, function ack (error) {
if (error) {
node.warn(error + ' Problem sending beat to follower');
}
});
}
});
}
var msg = { payload: 'tick',
start: _.clone(subBeat.names),
end: _.clone(node.subBeatEnds[node.subBeatNum]),
bpm: node.current_bpm
};
for (var j = 0; j < node.allSubBeatNames.length; j++) {
let subName = node.allSubBeatNames[j];
msg[subName] = node.beatCounter[subName];
if (node.subBeatCounts[subName] > 0) {
msg['beats_per_' + subName] = 1.0 / node.subBeatCounts[subName];
}
}
if (node.latency) {
msg.timeTag = node.thisBeatStart + subBeat.pos;
msg.latency = node.latency;
}
node.send(msg);
node.subBeatNum++;
if (node.subBeatNum >= node.fractionalIntervals.length) {
node.subBeatNum = 0;
var firstSubBeat = node.fractionalIntervals[0];
for (let i = 0; i < firstSubBeat.names.length; i++) {
var subName = firstSubBeat.names[i];
if (subName !== node.output) {
node.beatCounter[subName] = 0;
}
}
node.thisBeatStart = node.nextBeatStart;
// save next beat now - if a late beat arrives from a follower then it will be taken into account
node.nextBeatStart = node.thisBeatStart + node.interval;
}
}
// tap is used to set the tempo and potentially autostart
// taps at less than the minimum beat rate start counting again
function tap () {
const now = Date.now();
if (node.sharing === 'follower') {
return;
}
if (!Array.isArray(node.taps) || node.taps.length < 1) {
node.taps = [now];
return;
}
const timeSinceLastTap = now - node.taps[0];
const minBeatSeparation = 60000.0 / minBPM;
if (timeSinceLastTap > minBeatSeparation) {
node.taps = [now];
return;
}
if (node.taps.unshift(now) >= tapsToStart) {
let taps = node.taps;
let totalSeparation = taps[0] - taps[taps.length - 1];
let averageSeparation = totalSeparation / (taps.length - 1);
node.bpm = 60000.0 / averageSeparation;
if (!node.started) {
node.started = true;
setTimeout(beat, averageSeparation - node.latency);
}
node.taps = [];
}
}
}
RED.nodes.registerType('beat', BeatNode);
};