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

568 lines (490 loc) 20.3 kB
const dgram = require('dgram'); const osc = require('osc'); const fs = require('fs'); const glob = require('glob'); const path = require('path'); const nrp = require('node-red-contrib-properties'); // see http://danielnouri.org/docs/SuperColliderHelp/ServerArchitecture/Server-Command-Reference.html for details of SuperCollider commands module.exports = function (RED) { 'use strict'; const heartbeatInterval = 1000; const minBusNum = 16; // hopefully no clashes with sclang: default 8 input and 8 output buses const maxSynthID = 100000; const fps = 44100; // names for variables in the global context const gBufNum = 'supercolliderNextBufNum'; const gBusNum = 'supercolliderNextBusNum'; const gGroupNum = 'supercolliderNextGroupNum'; function SuperColliderNode (config) { // TODO make sure this is the only node of this type RED.nodes.createNode(this, config); var node = this; const synthtypes = require('./synthtypes'); const global = node.context().global; let properties = new nrp.NodeRedProperties(node, config, { host: { value: '127.0.0.1' }, port: { value: '57110' } }); node.groupID = nextGroupNum(); node.queuedSetup = []; // list of messages for audio setup that are waiting for the node to be ready reset(); this.on('input', function (msg) { properties.input(msg); if (msg.payload === 'reset') { reset(); return; } if (checkAudioSetup(msg)) { return; } if (msg.topic && msg.topic.startsWith('fxcontrol:')) { const fxcontrol = msg.topic.substring(10); const controlval = Number(msg.payload); setFXParam(fxcontrol, controlval, msg); return; } switch (msg.payload) { case 'tick': if (!node.ready) { break; } const synthtype = msg.synthtype; const looperAction = msg.looper; if (synthtype) { checkFXType(msg.fxpath, msg.details.bpm); checkSynthType(synthtype); sendOSC(note2sc(synthtype, msg)); } else if (looperAction) { checkFXType(msg.fxpath); const looperID = msg.nodeID; checkLooper(looperID); sendOSC(looper2sc(looperAction, looperID, msg)); } else { node.warn('No synthtype defined'); } break; default: // do nothing break; } }); this.on('close', function () { if (node.heartbeat) { clearTimeout(node.heartbeat); node.heartbeat = null; } }); function checkAudioSetup (msg) { if (!['synthtype', 'fxtype', 'looper'].includes(msg.topic)) { return false; } if (!node.ready) { node.queuedSetup.push(msg); return true; } let fxpath = msg.fxpath; switch (msg.topic) { case 'synthtype': let synthtype = msg.payload; checkSynthType(synthtype); checkFXType(fxpath); return true; // break; case 'fxtype': checkFXType(fxpath); return true; // break; case 'looper': let looperID = msg.nodeID; checkLooper(looperID); clearLooper(looperID); return true; // break; default: return false; } } // ehecks the synththype is valid and sends anything needed to SuperCollider function checkSynthType (synthtype) { if (!synthtypes[synthtype] && !isUserSample(synthtype)) { node.warn('SuperCollider unknown synthtype: ' + synthtype); return; } checkSynthDef(synthDefName(synthtype)); if (!isUserSample(synthtype) && synthtypes[synthtype].synth) { return; } checkSamples(synthtype); } function userSampleFile (synthtype) { let bits = synthtype.split('#'); if (bits.length !== 2) { return null; } if (bits[0] !== 'user-sample') { return null; } return bits[1]; } function isUserSample (synthtype) { return userSampleFile(synthtype) !== null; } function checkSamples (synthtype) { if (node.samples[synthtype]) { return; } const bufNum = nextBufNum(); node.samples[synthtype] = { 0: bufNum }; // could be varied by pitch let matches = []; // glob uses forward slashes even in Windows if (isUserSample(synthtype)) { const uploadDir = '/uploads'; const sampleName = path.basename(userSampleFile(synthtype)); matches.push(uploadDir + '/' + sampleName); } else { const sampdir = '/samples'; matches.push(sampdir + '/Dirt/' + synthtype + '/*.wav'); matches.push(sampdir + '/SonicPi/' + synthtype + '.flac'); matches.push(sampdir + '/Freesound/' + synthtype + '.wav'); matches.push(sampdir + '/VSCO/' + synthtype + '.wav'); } for (let match of matches) { glob(match, { nocase: true, root: __dirname }, function (er, files) { let fname = files[0]; if (fname) { // create and populate the buffer in SuperCollider var createMsg = { address: '/b_allocRead', args: [bufNum, fname] }; sendOSC(createMsg); } }); } } function checkSynthDef (synthDefName) { if (node.synthDefSent.has(synthDefName)) { return; } node.synthDefSent.add(synthDefName); const synthDefDir = '/synthdefs/compiled'; let matches = []; matches.push(synthDefDir + '/' + synthDefName + '.scsyndef'); matches.push(synthDefDir + '/sonic-pi/' + synthDefName + '.scsyndef'); for (let match of matches) { glob(match, { nocase: true, root: __dirname }, function (er, files) { let fname = files[0]; if (fname) { // send the synthdef to SuperCollider fs.readFile(fname, function (err, data) { if (err) { node.warn(' problem sending file for ' + synthDefName); node.warn(err); } else { const synthMsg = { address: '/d_recv', args: [data, 0] }; sendOSC(synthMsg); } }); } }); } } function checkFXType (fxpath, bpm) { // fxpath is a list of {nodeID, fxtype, parameters} objects, last element in the chain last in the list // builds chain: the path with the parameters removed // side effect is to claim buses and instantiate the relevant fxsynth // also updates the fx parameters // synth ID calculated from busNum // returns the input bus number of the first in the chain (i.e. the bus that any feeding synth should send its output to) if (!fxpath || fxpath.length === 0) { return 0; // then the final fx in the chain will send its output to audio out on bus 0 } let keyFull = JSON.stringify(path2chain(fxpath)); let [head, ...tail] = fxpath; let keyTail = JSON.stringify(path2chain(tail)); let tailBusNum = checkFXType(tail); if (node.chain2buses[keyFull]) { const bus = node.chain2buses[keyFull][head.nodeID]; // synth already exists, just set the bpm fx parameter return bus; } else { const headBusNum = nextBusNum(); const synthID = busNum2synthID(headBusNum); checkSynthDef(head.fxtype); let payload = [head.fxtype, synthID]; // specify position in the group: just before the next soundfx in if (tailBusNum) { payload.push(2, busNum2synthID(tailBusNum)); } else { payload.push(1, node.groupID); } payload.push('inBus', headBusNum); payload.push('out_bus', tailBusNum); const fxDetails = head.parameters; for (let key in fxDetails) { payload.push(key, Number(fxDetails[key])); } // delay the sending to give the synthdef time to arrive through checkSynthDef setTimeout(() => { sendOSC({ address: '/s_new', args: payload }); }, 50); let buses = clone(node.chain2buses[keyTail] || {}); buses[head.nodeID] = headBusNum; node.chain2buses[keyFull] = buses; return headBusNum; } } function setFXParam (fxcontrol, controlval, msg) { for (let fxchain in node.chain2buses) { let node2bus = node.chain2buses[fxchain]; for (let nodeID in node2bus) { if (nodeID === msg.nodeID) { let synthID = busNum2synthID(node2bus[nodeID]); let payload = [synthID, fxcontrol, Number(controlval)]; sendOSC({ address: '/n_set', args: payload }); } } } } function checkLooper (nodeID) { if (node.loopers[nodeID]) { return; } if (!node.ready) { return; } checkSynthDef('playSampleStereo'); checkSynthDef('recordSampleStereo'); const bufNum = nextBufNum(); node.loopers[nodeID] = bufNum; // create an empty buffer ready for recording const seconds = 20; // assumed max length for now const createMsg = { address: '/b_alloc', args: [node.loopers[nodeID], fps * seconds * 2, 2] }; sendOSC(createMsg); clearLooper(nodeID); } function clearLooper (nodeID) { // assumes checkLooper has already been called const zeroMsg = { address: '/b_zero', args: [node.loopers[nodeID]] }; sendOSC(zeroMsg); } function clone (obj) { return JSON.parse(JSON.stringify(obj)); } // extract the node ids function path2chain (fxpath) { return fxpath.map(e => ({ 'nodeID': e.nodeID, 'fxtype': e.fxtype })); } function nextBufNum () { return nextGlobalNum(gBufNum, 1, 0); } function nextBusNum () { return nextGlobalNum(gBusNum, 2, minBusNum); } function nextGroupNum () { return nextGlobalNum(gGroupNum, 1, 0); } function nextGlobalNum (which, inc, def) { let num = Number(global.get(which)); if (isNaN(num)) { num = def; } num += inc; global.set(which, num); return num; } function busNum2synthID (busNum) { return maxSynthID - busNum / 2; } function reset () { clearTimeout(node.heartbeat); if (node.udpPort) { node.udpPort.close(); node.udpPort = null; } node.ready = false; node.status({ fill: 'red', shape: 'ring', text: 'disconnected' }); let client = dgram.createSocket('udp4'); // unspecified port number makes OS select one at random node.udpPort = client; client.on('connect', function () { heartbeat(); }); client.on('error', function (err) { if (node.ready) { node.warn('SuperCollider connection error: ' + err); reset(); } }); client.on('message', function () { node.heartbeatResponse = true; if (!node.ready) { heartbeatHandler(); } }); client.connect(Number(properties.get('port')), properties.get('host')); } function clearSynthStore () { node.samples = {}; // map from synthtype to buffer (if required) node.loopers = {}; // map from nodeID to buffer node.synthDefSent = new Set(); node.chain2buses = {}; // keys are (JSON encoded) lists of node ids with fxtypes. Values are objects mapping from node id to busNum } function sendOSC (msg) { // do not send empty objects if (!Object.keys(msg) || !Object.keys(msg).length) { return; } node.udpPort.send(Buffer.from(osc.writePacket(msg))); } function synthDefName (synthtype) { if (isUserSample(synthtype)) { return 'playSampleMono'; } let synthDetails = synthtypes[synthtype]; if (synthDetails.synth) { if (synthDetails.tags.includes('sonic-pi')) { return 'sonic-pi-' + synthtype; } else { return synthtype; } } else if (synthDetails.stereo === true) { return 'playSampleStereo'; } else { return 'playSampleMono'; } } function note2sc (synthtype, msg) { // assumes checkSynth has already been run const synthdef = synthDefName(synthtype); let synthDetails; if (isUserSample(synthtype)) { synthDetails = synthtypes['user-sample']; } else { synthDetails = synthtypes[synthtype]; } // add the synth to the head of the root group // use node ID of -1 to auto-generate synth id let payload = [synthdef, -1, 0, node.groupID]; if (!synthDetails.synth) { payload.push('buffer', node.samples[synthtype][0]); // TODO check if sample is note-dependent let midibase; if (isUserSample(synthtype)) { let sampleFile = userSampleFile(synthtype); // search for a midi value at the end of the filename const base = path.basename(sampleFile, path.extname(sampleFile)); const rexp = /\d+$/; const matches = base.match(rexp); if (matches && matches.length > 0) { midibase = parseInt(matches[0]); } } else if (synthDetails.midibase) { midibase = synthDetails.midibase; } if (midibase) { payload.push('midibase', midibase); } } const noteDetails = msg.details; // copy all of the details into the payload to be sent via OSC let playmsg = playSynthSC(noteDetails, payload, msg); // avoid problems with DetectSilence leaving zombie synths at amp 0 if (noteDetails.amp > 0 && (!noteDetails.midi || noteDetails.midi >= 0)) { return playmsg; } else { return {}; } } function looper2sc (looperAction, looperID, msg) { // assumes checkLooper has already been run const synthDef = looperAction + 'SampleStereo'; let payload = [synthDef, -1, 0, node.groupID]; payload.push('buffer', node.loopers[looperID]); return playSynthSC(msg.details, payload, msg); } function playSynthSC (noteDetails, payload, msg) { for (let key in noteDetails) { payload.push(key, noteDetails[key]); if (key === 'midi') { // this is to work with the sonic pi synth defs payload.push('note', noteDetails.midi); } } let outBus = 0; if (msg.fxpath) { let fxChain = path2chain(msg.fxpath); let busMap = node.chain2buses[JSON.stringify(fxChain)]; outBus = busMap[fxChain[0].nodeID]; } payload.push('out_bus', outBus); const action = '/s_new'; let playmsg; if (msg.timeTag) { playmsg = { timeTag: osc.timeTag(0, msg.timeTag), packets: [ { address: action, args: payload } ] }; } else { playmsg = { address: action, args: payload }; } return playmsg; } function heartbeat () { const heartbeatMsg = { address: '/status', args: [] }; const heartbeatBuffer = Buffer.from(osc.writePacket(heartbeatMsg)); node.heartbeatResponse = false; node.udpPort.send(heartbeatBuffer); const drift = 1 + 0.1 * Math.random(); // in case there is more than one connection want to avoid clashes node.heartbeat = setTimeout(heartbeatHandler, heartbeatInterval * drift); } function heartbeatHandler () { if (node.heartbeat) { clearTimeout(node.heartbeat); } if (node.heartbeatResponse) { // any response indicates that the connection to SuperCollider works // and that SuperCollider is alive if (!node.ready) { sendOSC({ address: '/g_new', args: [node.groupID, 0, 0] }); sendOSC({ address: '/g_freeAll', args: [node.groupID] }); clearSynthStore(); setTimeout(() => { node.ready = true; for (let setupMsg of node.queuedSetup) { checkAudioSetup(setupMsg); } node.queuedSetup = []; node.status({ fill: 'green', shape: 'dot', text: 'connected' }); }, 100); } heartbeat(); } else { reset(); } } } RED.nodes.registerType('supercollider', SuperColliderNode); };