Files
ProdDash/tools/ableset-mock.js
T

199 lines
9.0 KiB
JavaScript
Raw Normal View History

#!/usr/bin/env node
'use strict';
/**
* A pretend AbleSet for testing the ableset module without the booth rig.
* Speaks AbleSet's OSC API over UDP: answers /subscribe, /getValues and
* /unsubscribe, sends /heartbeat every 2 s and pushes value changes while a
* setlist plays through — sections, a tempo per song, a section that loops
* twice, a song queued out of order, and the time left in song and set.
*
* node tools/ableset-mock.js [port] (default 39051)
* --stopped start with the transport stopped (press nothing; it stays)
* --no-live report Ableton Live as not connected
* --speed <x> play x times faster than real time (default 1)
*
* Point the AbleSet module at host 127.0.0.1 and this port.
*/
const dgram = require('dgram');
const { encodeMessage, decodePacket } = require('../modules/ableset/osc');
const argv = process.argv.slice(2);
const PORT = Number(argv.find((a) => /^\d+$/.test(a))) || 39051;
const SPEED = Number(argv[argv.indexOf('--speed') + 1]) > 0 && argv.includes('--speed') ? Number(argv[argv.indexOf('--speed') + 1]) : 1;
let playing = !argv.includes('--stopped');
const live = !argv.includes('--no-live');
// name, tempo, [section name, bars] …
const SONGS = [
['1- CALL TO WORSHIP', 72, [['INTRO', 4], ['READING', 12], ['OUTRO', 4]]],
['2 -ALLELUIA - A', 125, [['INTRO', 8], ['CHORUS', 8], ['VERSE 1', 16], ['PRE-CHORUS', 8], ['CHORUS', 16], ['BRIDGE', 16], ['CHORUS', 16], ['ENDING', 8]]],
['3- WHAT A GOD - A', 76.81, [['INTRO', 4], ['VERSE 1', 16], ['CHORUS', 8], ['VERSE 2', 16], ['CHORUS', 8], ['BRIDGE', 8], ['BRIDGE', 8], ['CHORUS', 16], ['TAG', 4], ['ENDING', 8]]],
['4- The Blood - Gb', 68, [['INTRO', 4], ['VERSE 1', 8], ['CHORUS', 8], ['VERSE 2', 8], ['CHORUS', 8], ['OUTRO', 4]]],
['LOOP', 100, [['LOOP', 16]]],
['5- BE PRAISED- Db', 140, [['INTRO', 8], ['VERSE', 16], ['CHORUS', 16], ['INSTRUMENTAL', 8], ['CHORUS', 16], ['ENDING', 4]], '6/8'],
];
const COLORS = ['default', 'blue', 'default', 'red', 'default', 'purple'];
// Lay the songs end to end on one arrangement, as AbleSet does.
let cursor = 8;
const songs = SONGS.map(([name, tempo, parts, sig], i) => {
const [n, d] = (sig || '4/4').split('/').map(Number);
const bpb = (n * 4) / d;
const start = cursor;
const sections = parts.map(([sname, bars]) => {
const s = { name: sname, start: cursor, end: cursor + bars * bpb };
cursor = s.end;
return s;
});
return { name, tempo, sig: [n, d], start, end: cursor, sections, color: COLORS[i] || 'default' };
});
const secs = (song, beats) => (beats * 60) / song.tempo;
const songDuration = (song) => secs(song, song.end - song.start);
let songIdx = 1;
let beats = songs[songIdx].start + 40; // a little way into the second song
let loopsLeft = 0; // section-loop repeats still to play
let queued = null; // index of a song queued out of order
const subscribers = new Map(); // "ip:port" → { ip, port }
const sock = dgram.createSocket('udp4');
const sent = new Map();
function values() {
const song = songs[songIdx];
const sIdx = Math.max(0, song.sections.findIndex((s) => beats >= s.start && beats < s.end));
const sect = song.sections[sIdx];
const nextSect = song.sections[sIdx + 1] || null;
const nextSongIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : -1;
const nextSong = songs[nextSongIdx] || null;
const leftInSong = secs(song, song.end - beats);
const leftInSet = leftInSong + songs.slice(songIdx + 1).reduce((a, s) => a + songDuration(s), 0);
const col = (name) => [name, name === 'default' ? '#FFFFFF' : { blue: '#3B82F6', red: '#EF4444', purple: '#A855F7' }[name] || '#FFFFFF'];
return {
'/global/isConnected': [live ? 1 : 0],
'/global/isLoadingFile': [0],
'/global/isPlaying': [playing ? 1 : 0],
'/global/isRecording': [0],
'/global/tempo': [song.tempo],
'/global/timeSignature': song.sig,
'/global/beatsPosition': [Math.floor(beats)],
'/global/finePosition': [beats],
'/setlist/name': ['260927 AB (mock)'],
'/setlist/songs': songs.map((s) => s.name),
'/setlist/songDurations': songs.map(songDuration),
'/setlist/songColors': songs.map((s) => s.color),
'/setlist/sections': song.sections.map((s) => s.name),
'/setlist/sectionColors': song.sections.map((s) => (s.name === 'BRIDGE' ? 'purple' : 'default')),
'/setlist/activeSongName': [song.name],
'/setlist/activeSongIndex': [songIdx],
'/setlist/activeSongStart': [song.start],
'/setlist/activeSongEnd': [song.end],
'/setlist/activeSongDuration': [songDuration(song)],
'/setlist/activeSongColor': col(song.color),
'/setlist/activeSectionName': [sect.name],
'/setlist/activeSectionIndex': [sIdx],
'/setlist/activeSectionStart': [sect.start],
'/setlist/activeSectionEnd': [sect.end],
'/setlist/activeSectionDuration': [secs(song, sect.end - sect.start)],
'/setlist/activeSectionColor': col(sect.name === 'BRIDGE' ? 'purple' : 'default'),
'/setlist/activeSectionLoopMode': [loopsLeft > 0 ? 'loop' : null],
'/setlist/activeSectionLoopCount': [loopsLeft],
'/setlist/isInActiveSectionLoop': [loopsLeft > 0 ? 1 : 0],
'/setlist/nextSectionName': [nextSect ? nextSect.name : null],
'/setlist/nextSectionIndex': [nextSect ? sIdx + 1 : -1],
'/setlist/nextSectionStart': [nextSect ? nextSect.start : null],
'/setlist/nextSectionEnd': [nextSect ? nextSect.end : null],
'/setlist/nextSongName': [nextSong ? nextSong.name : null],
'/setlist/nextSongIndex': [nextSongIdx],
'/setlist/nextSongDuration': [nextSong ? songDuration(nextSong) : null],
'/setlist/nextSongColor': col(nextSong ? nextSong.color : 'default'),
'/setlist/queuedName': [queued != null ? songs[queued].name : null, null],
'/setlist/queuedIndex': [queued != null ? queued : -1, -1],
'/setlist/queuedType': [queued != null ? 'song' : null],
'/setlist/loopEnabled': [0],
'/setlist/isCountingIn': [0],
'/setlist/remainingTimeInSong': [Math.round(leftInSong)],
'/setlist/remainingTimeInSet': [Math.round(leftInSet)],
'/audioInterfaces/connected': [1],
'/audioInterfaces/count': [1],
'/audioInterfaces/names': ['PlayAUDIO12'],
'/audioInterfaces/status': ['sceneA'],
'/version/app': ['mock'],
};
}
function sendTo(sub, address, args) {
sock.send(encodeMessage(address, args), sub.port, sub.ip); // null goes out as nil, as AbleSet sends it
}
function push(force = false) {
const now = values();
for (const [address, args] of Object.entries(now)) {
const key = JSON.stringify(args);
if (!force && sent.get(address) === key && address !== '/global/finePosition') continue;
if (address === '/global/finePosition' && !playing && !force) continue;
sent.set(address, key);
for (const sub of subscribers.values()) sendTo(sub, address, args);
}
}
sock.on('message', (buf, rinfo) => {
let msgs;
try { msgs = decodePacket(buf); } catch { return; }
for (const { address, args } of msgs) {
const ip = args[0] === 'auto' || !args[0] ? rinfo.address : String(args[0]);
const port = Number(args[1]) || rinfo.port;
if (address === '/subscribe') {
subscribers.set(`${ip}:${port}`, { ip, port });
sendTo({ ip, port }, '/subscribed', []);
console.log(`subscribed ${ip}:${port} (${args[2] || 'no name'})`);
} else if (address === '/unsubscribe') {
subscribers.delete(`${ip}:${port}`);
console.log(`unsubscribed ${ip}:${port}`);
} else if (address === '/getValues') {
const sub = { ip, port };
for (const [a, v] of Object.entries(values())) sendTo(sub, a, v);
}
}
});
setInterval(() => {
for (const sub of subscribers.values()) sendTo(sub, '/heartbeat', []);
}, 2000);
let last = Date.now();
setInterval(() => {
const now = Date.now();
const dtSec = ((now - last) / 1000) * SPEED;
last = now;
if (playing) {
const song = songs[songIdx];
const before = song.sections.find((s) => beats >= s.start && beats < s.end);
beats += dtSec * (song.tempo / 60);
// The second BRIDGE of song 3 loops twice before it lets go.
if (before && beats >= before.end && loopsLeft > 0) {
loopsLeft -= 1;
beats = before.start + (beats - before.end);
}
const after = song.sections.find((s) => beats >= s.start && beats < s.end);
if (after && after !== before && after.name === 'BRIDGE' && song.sections.indexOf(after) === 6) loopsLeft = 2;
if (beats >= song.end) {
// Stop at the end of each song, as a service does, then carry on.
const nextIdx = queued != null ? queued : songIdx + 1 < songs.length ? songIdx + 1 : 0;
queued = null;
songIdx = nextIdx;
beats = songs[songIdx].start;
playing = false;
push();
setTimeout(() => { playing = true; last = Date.now(); push(); }, 4000 / SPEED);
// Halfway through the set someone queues the last song early.
if (songIdx === 3) setTimeout(() => { queued = 5; push(); }, 8000 / SPEED);
}
}
push();
}, 100);
sock.bind(PORT, () => console.log(`AbleSet mock on udp ${PORT}${playing ? '' : ' (stopped)'}${live ? '' : ' (Live not connected)'}${SPEED !== 1 ? ` at ${SPEED}×` : ''}`));