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

443 lines (370 loc) 14.3 kB
const sc = require('./synth_common'); const multer = require('multer'); const glob = require('glob'); const path = require('path'); const _ = require('underscore'); const uploadDir = 'uploads'; // prefix for served GET requests const synthtypesURL = 'node-red-contrib-music/synthtypes'; const userSampleURL = 'node-red-contrib-music/usersamples'; let synthtypes = require('./synthtypes'); for (let synthtype in synthtypes) { let synthcontrols = synthtypes[synthtype].synthcontrols || {}; synthcontrols.pan = { 'description': 'position in stereo field: left (-1) or right (1)', 'min': -1, 'max': 1, 'default': 0 }; synthtypes[synthtype].synthcontrols = synthcontrols; } module.exports = function (RED) { 'use strict'; RED.httpAdmin.get('/' + synthtypesURL, function (req, res) { // so we can edit syntypes.json without having to restart the server synthtypes = require('./synthtypes'); res.json(synthtypes); }); RED.httpAdmin.get('/' + userSampleURL, async function (req, res) { // list of already uploaded files let matches = []; matches.push('/' + uploadDir + '/*.wav'); matches.push('/' + uploadDir + '/*.aiff'); matches.push('/' + uploadDir + '/*.aif'); let fpaths = []; for (let match of matches) { fpaths = fpaths.concat(glob.sync(match, { nocase: true, root: __dirname })); } let fnames = []; for (let fpath of fpaths) { fnames.push(path.basename(fpath)); } res.json(fnames); }); // adapted from https://github.com/expressjs/multer/issues/439 const storage = multer.diskStorage({ destination (req, file, cb) { cb(null, uploadDir); }, filename: function (req, file, cb) { cb(null, file.originalname); } }); let upload = multer({ storage }); RED.httpAdmin.post('/' + userSampleURL, upload.single('sample'), function (req, res) { // saving files that are uploaded const msg = 'Received file ' + req.file.path; res.send(msg); }); function SynthNode (config) { const configurables = { root: { 'default': 'C4' }, scale: { 'default': 'minor' }, volume: { 'default': 50 }, octave: { 'default': 0 }, synthtype: { 'default': 'kick' }, key: { 'default': 'C minor' }, degree: { 'default': 'I' } }; RED.nodes.createNode(this, config); const node = this; for (let conf in configurables) { configure(conf, config[conf]); } reset(); this.on('input', function (msg) { if (msg.topic && msg.topic.startsWith('synthcontrol:')) { const synthcontrol = msg.topic.substring(13); const controlval = Number(msg.payload); setSynthcontrol(synthcontrol, controlval); return; } switch (msg.topic) { default: switch (msg.payload) { case 'tick': configureTick(msg); handleTickSynth(msg); break; case 'reset': for (let conf in configurables) { configure(conf, config[conf]); } reset(); // just this once the reset message is not propagated break; case 'stop': // nothing to do break; case 'start': // nothing to do break; default: configureMsg(msg); } } }); this.on('close', function () { // nothing to do }); function handleTickSynth (msg) { if (Array.isArray(msg.note)) { msg.note.forEach(function (noteVal) { sendNote(noteVal, msg); }); } else { sendNote(msg.note, msg); } } function sendNote (noteVal, msg) { let midi; if (isNaN(msg.midi)) { if (!isNaN(noteVal)) { midi = note2midi(noteVal); } } else { midi = msg.midi; } const amp = sc.volume2amp(node); let details = { amp }; if (midi) { Object.assign(details, { midi }); } const bpm = msg.bpm || node.context().flow.get('bpm') || node.context().global.get('bpm'); if (msg.beats) { details.beats = msg.beats; if (bpm) { // set release to 0.25 beats let release = 0.25 * 60 / bpm; // tried subtracting release time but it seemed to leave gaps let sustain = msg.beats * 60 / bpm; Object.assign(details, { bpm, sustain, release }); } } for (let p in node.parameters) { details[p] = node.parameters[p]; } let synthtypeExtended = node.synthtype; if (node.synthtype === 'user-sample') { synthtypeExtended = synthtypeExtended + '#' + node.sampleName; } let noteMsg = { payload: 'tick', details, synthtype: synthtypeExtended }; if (msg.timeTag) { noteMsg.timeTag = msg.timeTag; } // from v2.0 all synth instructions area sent this way node.send(noteMsg); } function setSynthcontrol (synthcontrol, value) { let synthcontrolSpecs = synthtypes[node.synthtype].synthcontrols || {}; // all synths allow pan at least synthcontrolSpecs.pan = { 'note': { 'description': 'position in stereo field: left (-1) or right (1)', 'min': -1, 'max': 1, 'default': 0 } }; if (!synthtypes[node.synthtype].synthcontrols[synthcontrol]) { node.warn('No such synthcontrol: ' + synthcontrol); return; } node.parameters[synthcontrol] = Number(value); } function reset () { node.tuned = config.tuned; node.sampleName = config.sampleName; node.parameters = node.parameters || {}; if (config.synthcontrols) { for (let synthcontrol in config.synthcontrols) { setSynthcontrol(synthcontrol, config.synthcontrols[synthcontrol]); } } setTimeout(() => { node.send({ topic: 'synthtype', payload: node.synthtype }); }, 200); } function isTuned () { return synthtypes[node.synthtype] && synthtypes[node.synthtype].tuned; } // turn note number (degree of scale) into midi function note2midi (note) { if (Array.isArray(note)) { return _.map(note, note2midi); } if (!isTuned()) { return; } if (note === null) { return -1; } note = Number(note); const flow = node.context().flow; const global = node.context().global; var root = node.root; var scale = node.scale; var degree = node.degree; var globalkey = flow.get('key') || global.get('key'); var globalroot; var globalscale; if (typeof globalkey === 'string') { var bits = globalkey.split(' '); globalroot = bits.shift(); globalscale = bits.join(' '); } if (root === '') { root = flow.get('root') || global.get('root') || globalroot || configurables.root['default']; } if (scale === '') { scale = flow.get('scale') || global.get('scale') || globalscale || configurables.scale['default']; } scale = scale.toLowerCase(); if (degree === '') { degree = flow.get('degree') || global.get('degree') || configurables.degree['default']; } // turn the degree into an offset const roman = { I: 1, II: 2, III: 3, IV: 4, V: 5, VI: 6, VII: 7, VIII: 8 }; var noteoffset = 0; if (_.isString(degree) && roman[degree.toUpperCase()]) { noteoffset = roman[degree.toUpperCase()] - 1; } var midiroot; if (isNaN(root)) { const name2midi = { C: 60, D: 62, E: 64, F: 65, G: 67, A: 69, B: 71 }; var rootbits = root.toUpperCase().split(''); var base = rootbits.shift(); midiroot = name2midi[base]; if (midiroot === undefined) { node.warn('Scale root should be a midi number or start with a letter A-G'); return; } var next = rootbits.shift(); if (next === '#' || next === 's') { midiroot++; } else if (next === 'b') { midiroot--; } else if (next !== undefined) { rootbits.unshift(next); } if (rootbits.length > 0) { var octave = Number(rootbits.join('')); if (!isNaN(octave)) { midiroot += (octave - 4) * 12; } } } else { if (root < 0 && root > 127) { node.warn('Scale root must be in range 0-127'); return; } midiroot = root; } note += noteoffset; const intervals = { minor: [0, 2, 3, 5, 7, 8, 10, 12], major: [0, 2, 4, 5, 7, 9, 11, 12], dorian: [0, 2, 3, 5, 7, 9, 10, 12], mixolydian: [0, 2, 4, 5, 7, 9, 10, 12], lydian: [0, 2, 4, 6, 7, 9, 11, 12], 'major pentatonic': [0, 2, 4, 7, 9, 12], 'minor pentatonic': [0, 3, 5, 7, 10, 12], blues: [0, 3, 5, 6, 7, 10, 12], chromatic: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12], 'whole tone': [0, 2, 4, 6, 8, 10, 12], 'octatonic': [0, 2, 3, 5, 6, 8, 9, 11, 12] }; if (!intervals[scale]) { node.warn('Unrecognised scale: ' + scale); scale = configurables.scale['default']; node.warn('Using: ' + scale); } var offsets = intervals[scale]; var midi = midiroot; // work out notes above the offset values by shifting up an octave while (note > offsets.length) { note -= offsets.length - 1; midi += offsets[offsets.length - 1]; } // work out notes below the offset values by shifting down an octave // notes 0 and -1 are the same as +1 if (note === 0 || note === -1) { note = 1; } var negative = false; while (note < 0) { negative = true; note += offsets.length - 1; midi -= offsets[offsets.length - 1]; } midi += node.octave * 12; if (negative) { midi += offsets[note + 1]; } else { midi += offsets[note - 1]; } return midi; } function configureTick (msg) { for (var configurable in configurables) { var val = msg[configurable]; if (val != null) { configure(configurable, val); } } } function configureMsg (msg) { for (var configurable in configurables) { if (msg.topic === configurable) { configure(configurable, msg.payload); } } } function configure (config, val) { if (!Object.keys(configurables).includes(config)) { node.warn(config + ' is not configurable'); return; } var def = configurables[config].default; switch (config) { case 'volume': var newVol = Number(val); if (Number.isNaN(newVol)) { if (Number.isNaN(node.volume)) { node.volume = def; } } else { node.volume = newVol; } node.volume = Math.min(100, Math.max(0, node.volume)); break; case 'key': if (val) { var bits = val.split(' '); configure('root', bits.shift()); configure('scale', bits.shift()); } break; case 'synthtype': node[config] = val; reset(); break; default: node[config] = val; } } } RED.nodes.registerType('synth', SynthNode); };