#!/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 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}×` : ''}`));