199 lines
9.0 KiB
JavaScript
199 lines
9.0 KiB
JavaScript
#!/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}×` : ''}`));
|