Compare commits

..
Author SHA1 Message Date
Waters ChurchandClaude Fable 5.1 b5c539ba05 Release builds: Intel disk images, a Windows bundle, and where to download them
launcher/macos/build.sh takes ARCH=x86_64 for Intel Macs — the Swift target
and the embedded Node follow (nodejs.org calls that one darwin-x64). The
default stays arm64.

launcher/windows/build.sh is new: the same payload plus Node for Windows,
checksum-verified against nodejs.org, and ProdDash.bat, which keeps the
server running and starts it again on exit code 75 — the admin page's update
restart — the way the macOS launcher does. No Windows launcher app yet.

The README's Run it section now points at the Releases page and the
ProdDash-releases repo that holds the files, one line each for Apple
silicon, Intel and Windows.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-24 17:53:19 -04:00
13 changed files with 156 additions and 2208 deletions
+2
View File
@@ -14,6 +14,8 @@ modules/propresenter-timers/ltc-capture
# the launcher's build output, and its cache of the Node runtime it embeds
launcher/macos/build/
launcher/macos/.cache/
launcher/windows/build/
launcher/windows/.cache/
# Compiled per-machine, source sits next to it (see spl-capture.swift).
modules/spl-meter/spl-capture
-1
View File
@@ -45,7 +45,6 @@ title-bar controls). The rules that matter most:
`node band-lineup-display/tmp/pp-mock.js 1599` (ProPresenter).
`node tools/slack-mock.js 24716` (Slack; run ProdDash with `SLACK_API_BASE=http://127.0.0.1:24716/api`).
`node tools/pco-band-mock.js 24710` (Planning Center for Band Lineup; run ProdDash with `BAND_LINEUP_API_BASE=http://127.0.0.1:24710`).
`node tools/ableset-mock.js 39071` (AbleSet over OSC/UDP; point the AbleSet module at `127.0.0.1:39071`; `--stopped`, `--no-live`, `--speed 4`).
## Reference code (read-only forks — don't develop these here)
+26 -2
View File
@@ -30,9 +30,19 @@ and a clear degraded state, then recover on their own.
## Run it
Ready-made builds are on the
[Releases page](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash/releases);
the files themselves live in
[ProdDash-releases](http://chronicles-ab.waterschurch.org:3000/michael/ProdDash-releases).
There is a disk image for Apple silicon Macs, one for Intel Macs, and a
bundle for 64-bit Windows. Every build updates itself afterwards from the
admin page, so the download only has to be recent, not current.
### macOS — the app
Open the disk image and drag **ProdDash** to Applications. That's the whole
Download the disk image for your Mac — `ProdDash-<version>-arm64.dmg` for
Apple silicon, `ProdDash-<version>-x86_64.dmg` for Intel — open it and drag
**ProdDash** to Applications. That's the whole
installation: the app carries the dashboard and its own Node runtime, so
nothing else has to be installed and nothing can be missing. It lives in the
menu bar, starts and stops the server, can open at login, and holds the
@@ -43,12 +53,26 @@ a launchd job) simply doesn't have them, and an LTC input goes silently to
zeroes.
Build the disk image from a checkout with
[launcher/macos](launcher/macos)/`build.sh`:
[launcher/macos](launcher/macos)/`build.sh` (`ARCH=x86_64` in front of it
for Intel):
```bash
cd launcher/macos && ./build.sh --dmg
```
### Windows — the bundle
Download `ProdDash-<version>-win-x64.zip`, unzip it somewhere you can write
to — `C:\ProdDash`, say, not Program Files, because updates write into the
folder — and double-click **ProdDash.bat**. The window it opens is the
dashboard running: it stays open, and it starts the server again when the
admin page applies an update. Allow `node.exe` on private networks when the
firewall asks, so other screens can reach it. Settings and layouts live in
`%APPDATA%\ProdDash`, so a new bundle never loses them. To start with
Windows, put a shortcut to `ProdDash.bat` in `shell:startup`. There is no
menu-bar-style launcher for Windows yet; the bundle is built with
[launcher/windows](launcher/windows)/`build.sh`.
### Anywhere else, or from a checkout
Requires [Node.js](https://nodejs.org) 18 or newer.
-5
View File
@@ -811,11 +811,6 @@ client-side failures (a tile that fails to start says so in the tile).
Application ID + Secret under PCO Plan in `/admin`, save, then pick
"Sunday Service" from the Service type list. Its item titles match
`pp-mock.js`, so running both mocks exercises the ProPresenter matching.
- AbleSet: `node tools/ableset-mock.js 39071` — a setlist playing through
over AbleSet's OSC API (UDP): sections, a tempo and meter per song, a
section that loops twice, a song queued out of order, heartbeats.
`--stopped`, `--no-live` and `--speed 4` exercise the other states. Point
the AbleSet module at host `127.0.0.1`, port `39071`.
**The resilience checklist** — every module must pass this before it's done:
+1
View File
@@ -51,6 +51,7 @@ the cycle rather than spinning forever.
```bash
./build.sh # build/ProdDash.app — the whole application
./build.sh --dmg # …and build/ProdDash-<version>-arm64.dmg
ARCH=x86_64 ./build.sh --dmg # the same for Intel Macs (…-x86_64.dmg)
./build.sh --install # …and copy it to /Applications
./build.sh --run # …and open it
./build.sh --notarize # with --dmg: notarise and staple (Developer ID)
+10 -3
View File
@@ -11,6 +11,8 @@
# ./build.sh --dev launcher only — no Node, no server payload, so
# it runs against the checkout. Seconds, not
# minutes, while working on the launcher itself.
# ARCH=x86_64 ./build.sh the same for Intel Macs (default: arm64, Apple
# silicon); the Node it embeds follows.
#
# Signing: a "Developer ID Application" certificate is used when one is
# installed (override with CODESIGN_IDENTITY). Without one the app is signed
@@ -27,8 +29,13 @@ LAUNCHER_VERSION="1.0.0"
# The Node the app ships. Bump it here; the download is checksum-verified
# against nodejs.org's own SHASUMS256.txt and cached in .cache/.
NODE_VERSION="v24.21.0"
ARCH="arm64"
ARCH="${ARCH:-arm64}" # arm64 (Apple silicon) or x86_64 (Intel)
DEPLOYMENT_TARGET="13.0"
case "$ARCH" in
arm64) NODE_ARCH="arm64" ;;
x86_64) NODE_ARCH="x64" ;; # nodejs.org's name for it
*) echo "build.sh: ARCH must be arm64 or x86_64, not $ARCH" >&2; exit 2 ;;
esac
REPO_ROOT="../.."
OUT_DIR="build"
@@ -106,7 +113,7 @@ fi
# ── the server, and the Node that runs it ────────────────────────────────
if [[ $DEV -eq 0 ]]; then
TARBALL="node-$NODE_VERSION-darwin-$ARCH.tar.gz"
TARBALL="node-$NODE_VERSION-darwin-$NODE_ARCH.tar.gz"
mkdir -p "$CACHE_DIR"
if [[ ! -f "$CACHE_DIR/$TARBALL" ]]; then
echo " fetching Node $NODE_VERSION (~50 MB, cached for next time)"
@@ -125,7 +132,7 @@ if [[ $DEV -eq 0 ]]; then
echo " embedding Node $NODE_VERSION"
mkdir -p "$CONTENTS/Resources/node/bin"
tar -xzf "$CACHE_DIR/$TARBALL" -C "$CONTENTS/Resources/node/bin" --strip-components 2 \
"node-$NODE_VERSION-darwin-$ARCH/bin/node"
"node-$NODE_VERSION-darwin-$NODE_ARCH/bin/node"
echo " embedding ProdDash $VERSION"
mkdir -p "$PAYLOAD/config"
+117
View File
@@ -0,0 +1,117 @@
#!/bin/zsh
# Build the Windows bundle — ProdDash-<version>-win-x64.zip: the dashboard,
# its own Node runtime and a start script, so nothing has to be installed on
# the Windows machine. Unzip, double-click ProdDash.bat, done.
#
# ./build.sh build/ProdDash-<version>-win-x64.zip
#
# Builds on macOS or Linux (zsh, curl, shasum, unzip, zip). Node for Windows
# comes from nodejs.org, checksum-verified against its SHASUMS256.txt and
# cached in .cache/. There is no Windows launcher app yet: ProdDash.bat keeps
# the server running and starts it again when the admin page applies an
# update (the server exits with code 75 to ask for that).
set -e -u
cd "$(dirname "$0")"
APP_NAME="ProdDash"
# The Node the bundle ships — keep in step with launcher/macos/build.sh.
NODE_VERSION="v24.21.0"
NODE_DIST="node-$NODE_VERSION-win-x64"
REPO_ROOT="../.."
OUT_DIR="build"
CACHE_DIR=".cache"
for tool in curl shasum unzip zip; do
command -v "$tool" >/dev/null || { echo "build.sh: $tool not found" >&2; exit 1; }
done
VERSION=$(sed -n 's/.*"version" *: *"\([^"]*\)".*/\1/p' "$REPO_ROOT/package.json" | head -1)
[[ -n "$VERSION" ]] || { echo "build.sh: can't read the version from package.json" >&2; exit 1; }
NAME="$APP_NAME-$VERSION-win-x64"
STAGE="$OUT_DIR/$NAME"
echo "ProdDash $VERSION — building the Windows bundle"
rm -rf "$OUT_DIR"
mkdir -p "$STAGE/node" "$STAGE/config" "$CACHE_DIR"
# ── Node for Windows ─────────────────────────────────────────────────────
ZIPFILE="$NODE_DIST.zip"
if [[ ! -f "$CACHE_DIR/$ZIPFILE" ]]; then
echo " fetching Node $NODE_VERSION for Windows (~30 MB, cached for next time)"
curl -fsSL --retry 3 -o "$CACHE_DIR/$ZIPFILE.part" "https://nodejs.org/dist/$NODE_VERSION/$ZIPFILE"
mv "$CACHE_DIR/$ZIPFILE.part" "$CACHE_DIR/$ZIPFILE"
fi
SUMS="$CACHE_DIR/SHASUMS256-$NODE_VERSION.txt"
[[ -f "$SUMS" ]] || curl -fsSL --retry 3 -o "$SUMS" "https://nodejs.org/dist/$NODE_VERSION/SHASUMS256.txt"
# This binary ships under ProdDash's name — check it every build.
WANT=$(awk -v f="$ZIPFILE" '$2 == f {print $1}' "$SUMS")
GOT=$(shasum -a 256 "$CACHE_DIR/$ZIPFILE" | awk '{print $1}')
if [[ -z "$WANT" || "$WANT" != "$GOT" ]]; then
echo "build.sh: $ZIPFILE does not match nodejs.org's checksum — refusing to ship it" >&2
exit 1
fi
echo " embedding Node $NODE_VERSION (node.exe and its licence)"
unzip -q -j "$CACHE_DIR/$ZIPFILE" "$NODE_DIST/node.exe" "$NODE_DIST/LICENSE" -d "$STAGE/node"
# ── ProdDash ─────────────────────────────────────────────────────────────
echo " embedding ProdDash $VERSION"
cp "$REPO_ROOT/server.js" "$REPO_ROOT/package.json" "$STAGE/"
cp -R "$REPO_ROOT/public" "$REPO_ROOT/modules" "$STAGE/"
# Only the checked-in defaults: config/ also holds this machine's own
# modules.json and layouts when ProdDash has ever run from the checkout.
cp "$REPO_ROOT/config/proddash.json" "$STAGE/config/"
find "$STAGE" -name '.DS_Store' -delete
# ── the start script and the read-me, with Windows line endings ──────────
crlf() { awk '{ printf "%s\r\n", $0 }' > "$1"; }
crlf "$STAGE/ProdDash.bat" <<'BAT'
@echo off
rem ProdDash - double-click to start. Keep this window open: closing it stops the dashboard.
title ProdDash
cd /d "%~dp0"
rem The server exits with code 75 after applying an update from the admin page; start it again.
set PRODDASH_LAUNCHER=1
:run
"%~dp0node\node.exe" server.js
set CODE=%ERRORLEVEL%
if %CODE% EQU 75 goto run
echo.
echo ProdDash stopped (exit code %CODE%). Press any key to close this window.
pause >nul
BAT
crlf "$STAGE/README.txt" <<README
ProdDash $VERSION for Windows (64-bit)
=======================================
Nothing to install. This folder holds the dashboard and its own copy of Node.
1. Move this folder somewhere you can write to, for example C:\ProdDash
(not Program Files: updates write into this folder).
2. Double-click ProdDash.bat. A window opens and stays open while the
dashboard runs; closing it stops the dashboard.
- If Windows warns about the file, choose More info, then Run anyway.
- When Windows Defender Firewall asks about node.exe, allow it on
private networks so other screens can reach the dashboard.
3. Open http://localhost:24500 on this PC. Other devices on the network use
http://<this PC's IP address>:24500. Settings are on the admin page,
http://localhost:24500/admin.
Your settings and layouts live in %APPDATA%\ProdDash, not in this folder,
so unzipping a new version or updating never loses them.
Start with Windows: press Win+R, type shell:startup, press Enter, and put a
shortcut to ProdDash.bat in the folder that opens.
Updates: the admin page's Update now fetches the new version into this
folder and restarts the dashboard; the window stays open. A copy of the
version it replaced is kept for a short while in case the new one will not
start.
README
# ── zip ──────────────────────────────────────────────────────────────────
echo " zipping"
(cd "$OUT_DIR" && zip -q -r -X "$NAME.zip" "$NAME")
echo "built $OUT_DIR/$NAME.zip ($(du -sh "$OUT_DIR/$NAME.zip" | awk '{print $1}'))"
-727
View File
@@ -1,727 +0,0 @@
/* AbleSet — client part.
One factory draws every AbleSet tile; `moduleApi.variant` says which one
(the server's tiles() list). All of them read the same stream: `state`
whenever something changes, `pos` five times a second while playing.
Between packets the playhead and the countdowns are extrapolated from the
tempo and the clock, so bars glide instead of stepping. */
const VIEWS = ['now', 'sections', 'setlist', 'transport', 'tempo', 'song-time', 'set-time', 'section', 'next-song', 'status'];
/* AbleSet names its colours after Live's palette; these are close enough to
read as the same colour on a dark tile. Unknown names draw no colour. */
const NAMED_COLORS = {
red: '#ff4d4f', orange: '#ff8f3a', yellow: '#f5d442', lime: '#a3e635', green: '#34d399',
mint: '#5eead4', teal: '#2dd4bf', cyan: '#22d3ee', sky: '#38bdf8', blue: '#3b82f6',
indigo: '#6366f1', purple: '#a855f7', violet: '#8b5cf6', magenta: '#e879f9', pink: '#f472b6',
rose: '#fb7185', brown: '#b0794a', gray: '#9ca3af', grey: '#9ca3af', white: '#f5f5f5',
};
const ICON_STRIP = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><rect x="1.5" y="5" width="4" height="6" rx="1"/><rect x="6.5" y="4" width="4" height="8" rx="1"/><rect x="11.5" y="5" width="3" height="6" rx="1"/></svg>';
const ICON_WHEEL = '<svg viewBox="0 0 16 16" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="M4 2.5h8M3 6h10M3 10h10M4 13.5h8"/><rect x="2" y="5" width="12" height="6" rx="1.2"/></svg>';
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, (c) => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' }[c]));
}
/** 83 → "01:23", 3725 → "1:02:05". Whole seconds round down, as AbleSet's own display does. */
function fmt(sec) {
if (sec == null || !Number.isFinite(sec)) return '--:--';
const s = Math.max(0, Math.floor(sec + 1e-6));
const h = Math.floor(s / 3600);
const m = Math.floor((s % 3600) / 60);
const ss = String(s % 60).padStart(2, '0');
return h ? `${h}:${String(m).padStart(2, '0')}:${ss}` : `${String(m).padStart(2, '0')}:${ss}`;
}
function fmtBpm(t) {
if (t == null) return '--';
return (Math.round(t * 100) / 100).toString();
}
function colorFor(value) {
if (!value) return null;
if (/^#[0-9a-f]{6}$/i.test(value)) return value;
return NAMED_COLORS[String(value).toLowerCase()] || null;
}
export default function create({ root, moduleApi }) {
const view = VIEWS.includes(moduleApi.variant) ? moduleApi.variant
: moduleApi.variant ? '' : 'now'; // an entry that vanished draws nothing
const prefs = () => moduleApi.instanceSettings || {};
const on = (k) => prefs()[k] !== false;
let state = null;
let pos = { beats: null, remainingSong: null, remainingSet: null };
let posAt = 0;
let stream = null;
let raf = 0;
let resizeObserver = null;
let tickTimer = null;
/* ── moving values, extrapolated to this frame ─────────────────── */
const moving = () => Boolean(state && state.reachable && state.playing);
const dt = () => (moving() ? (performance.now() - posAt) / 1000 : 0);
function beatsNow() {
if (pos.beats == null) return null;
const t = state?.tempo;
return t ? pos.beats + dt() * (t / 60) : pos.beats;
}
const songLeft = () => (pos.remainingSong == null ? null : Math.max(0, pos.remainingSong - dt()));
const setLeft = () => (pos.remainingSet == null ? null : Math.max(0, pos.remainingSet - dt()));
function fraction(start, end, b) {
if (start == null || end == null || b == null || end <= start) return null;
return Math.min(1, Math.max(0, (b - start) / (end - start)));
}
function songsLeft() {
if (!state?.song || state.song.index == null) return null;
return Math.max(0, state.setlist.songs.length - state.song.index - 1);
}
/** Beats per bar from the time signature (Live counts quarter notes). */
function beatsPerBar() {
const ts = state?.timeSig;
return ts && ts[0] > 0 && ts[1] > 0 ? (ts[0] * 4) / ts[1] : 4;
}
/** Bar and beat within the song, 1-based. */
function barBeat() {
const b = beatsNow();
const start = state?.song?.start ?? 0;
if (b == null) return null;
const rel = Math.max(0, b - start);
const bpb = beatsPerBar();
const ts = state?.timeSig || [4, 4];
const beatLen = 4 / (ts[1] || 4);
const bar = Math.floor(rel / bpb) + 1;
const beat = Math.floor((rel % bpb) / beatLen + 1e-6) + 1;
return { bar, beat, of: ts[0] || 4 };
}
function tone(sec) {
if (sec == null || !moving()) return '';
const warn = Number(prefs().warnAt ?? 30);
const alert = Number(prefs().alertAt ?? 10);
if (sec <= alert) return 'alert';
if (sec <= warn) return 'warn';
return '';
}
/* ── fitting one-line text to its box ──────────────────────────── */
/** Shrink a nowrap element until it fits the width its parent gives it. */
function fit(el) {
if (!el || !el.isConnected) return;
el.style.fontSize = '';
const max = parseFloat(getComputedStyle(el).fontSize) || 16;
const avail = el.parentElement ? el.parentElement.clientWidth : el.clientWidth;
let size = max;
for (let i = 0; i < 3 && el.scrollWidth > avail + 0.5 && size > 9; i += 1) {
size = Math.max(9, size * (avail / el.scrollWidth) * 0.98);
el.style.fontSize = `${size}px`;
}
}
const fitAll = () => root.querySelectorAll('[data-fit]').forEach(fit);
/** Set text, refitting only when it changed. */
function text(el, value) {
if (!el) return;
const v = String(value ?? '');
if (el.textContent === v) return;
el.textContent = v;
if (el.hasAttribute('data-fit')) fit(el);
}
function show(el, visible) {
if (el) el.hidden = !visible;
}
/* ── skeleton per view ─────────────────────────────────────────── */
root.innerHTML = `<div class="as-root as-${view || 'none'}"><div class="as-offline" hidden></div><div class="as-body"></div></div>`;
const shell = root.firstElementChild;
const body = shell.querySelector('.as-body');
const offlineEl = shell.querySelector('.as-offline');
const $ = (sel) => body.querySelector(sel);
const BAR = `<div class="as-bar"><div class="as-bar-done"></div><div class="as-bar-sect"><div class="as-bar-played"></div></div></div>`;
const SKELETONS = {
now: `
<div class="as-title-box"><div class="as-title" data-fit></div></div>
<div class="as-badges">
<span class="as-badge b-tempo"></span><span class="as-badge b-meter"></span><span class="as-badge b-dur"></span><span class="as-badge b-clock"></span><span class="as-badge b-section"></span>
</div>
${BAR}
<div class="as-next">
<div class="as-next-label">Next:</div>
<div class="as-next-box"><div class="as-next-title" data-fit></div></div>
<div class="as-left"></div>
</div>`,
sections: `
<div class="as-song-name" data-fit></div>
<div class="as-viewport"><div class="as-track"></div></div>`,
setlist: `
<div class="as-set-head"><span class="as-set-name"></span></div>
<div class="as-list"></div>
<div class="as-set-foot"></div>`,
transport: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-pos"></span><span class="as-loop"></span></div>`,
tempo: `
<div class="as-big-box"><div class="as-big" data-fit><span class="as-bpm"></span><small> BPM</small></div></div>
<div class="as-sub"><span class="as-meter"></span></div>
<div class="as-beats"></div>`,
'song-time': `
<div class="as-caption" data-fit></div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub as-sub-time"></div>
${BAR}`,
'set-time': `
<div class="as-caption" data-fit>Set</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-left-count"></span><span class="as-end"></span></div>`,
section: `
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-sect"></span><span class="as-count"></span></div>
<div class="as-sect-bar"><div></div></div>`,
'next-song': `
<div class="as-caption">Next</div>
<div class="as-big-box"><div class="as-big" data-fit></div></div>
<div class="as-sub"><span class="as-next-dur"></span></div>
<div class="as-queued" hidden></div>`,
status: `<div class="as-rows"></div>`,
};
body.innerHTML = SKELETONS[view] || '';
/* ── shared pieces ─────────────────────────────────────────────── */
/** The three-part song bar: played sections, this section, the playhead. */
function paintBar(bar) {
if (!bar) return;
const s = state?.song;
const sect = state?.section;
const b = beatsNow();
const len = s && s.end > s.start ? s.end - s.start : 0;
const at = (v) => (len ? Math.min(100, Math.max(0, ((v - s.start) / len) * 100)) : 0);
const sStart = sect && sect.start != null ? at(sect.start) : 0;
const sEnd = sect && sect.end != null ? at(sect.end) : 0;
const head = b != null ? at(b) : 0;
bar.style.setProperty('--done', `${sStart}%`);
bar.style.setProperty('--sect-l', `${sStart}%`);
bar.style.setProperty('--sect-w', `${Math.max(0, sEnd - sStart)}%`);
const inSect = sEnd > sStart ? Math.min(100, Math.max(0, ((head - sStart) / (sEnd - sStart)) * 100)) : 0;
bar.style.setProperty('--played', `${inSect}%`);
}
function songTint(el, hex) {
if (hex) el.style.setProperty('--song', hex);
else el.style.removeProperty('--song');
el.classList.toggle('has-song-color', Boolean(hex));
}
/* ── NOW PLAYING ───────────────────────────────────────────────── */
const nowView = {
render() {
const s = state;
const song = s.song;
text($('.as-title'), song ? song.name : s.setlist.name || 'No song');
shell.classList.toggle('is-playing', moving());
songTint(shell, on('songColors') && song ? colorFor(song.color) : null);
text($('.b-tempo'), s.tempo != null ? `${fmtBpm(s.tempo)} BPM` : '');
text($('.b-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
text($('.b-dur'), song && song.duration != null ? fmt(song.duration) : '');
const sect = s.section;
text($('.b-section'), sect ? sect.name : '');
const sc = on('songColors') && sect ? colorFor(sect.color) : null;
$('.b-section').style.color = sc || '';
show($('.b-tempo'), on('showTempo') && s.tempo != null);
show($('.b-meter'), on('showMeter') && Boolean(s.timeSig));
show($('.b-dur'), on('showDuration') && song?.duration != null);
show($('.b-clock'), on('showClock') && pos.remainingSong != null);
show($('.b-section'), on('showSection') && Boolean(sect?.name));
show($('.as-bar'), on('showProgress') && Boolean(song));
const next = s.queued?.type === 'song' ? { name: s.queued.song } : s.nextSong;
text($('.as-next-label'), s.queued?.type === 'song' ? 'Queued:' : 'Next:');
$('.as-next').classList.toggle('is-queued', s.queued?.type === 'song');
text($('.as-next-title'), next ? next.name : 'End of set');
show($('.as-next-label'), on('showNext'));
show($('.as-next-box'), on('showNext'));
show($('.as-left'), on('showLeft') && songsLeft() != null);
this.frame();
},
frame() {
const left = songLeft();
const clockMode = prefs().clock || 'remaining';
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const clock = clockMode === 'elapsed' ? fmt(elapsed)
: clockMode === 'both' ? `${fmt(elapsed)} / -${fmt(left)}` : `-${fmt(left)}`;
const c = $('.b-clock');
text(c, clock);
c.dataset.tone = tone(left);
paintBar($('.as-bar'));
const n = songsLeft();
if (n != null) text($('.as-left'), `${n} song${n === 1 ? '' : 's'} (${fmt(setLeft())}) left`);
},
};
/* ── SONG SECTIONS: a strip, or a lottery wheel ────────────────── */
const WHEEL_STEP = 30; // degrees between wheel faces
let sectionKey = '';
let wheelButton = null;
const sectionsView = {
style() { return prefs().style === 'wheel' ? 'wheel' : 'strip'; },
build() {
const track = $('.as-track');
const list = state.sections || [];
const key = `${state.song?.name}|${list.map((x) => x.name).join('|')}`;
if (key === sectionKey && track.children.length === list.length) return;
sectionKey = key;
track.innerHTML = list.map((x, i) => `<div class="as-pill" data-i="${i}"><span class="as-pill-fill"></span><span class="as-pill-text">${esc(x.name)}</span></div>`).join('')
|| '<div class="as-pill is-empty"><span class="as-pill-text">No sections</span></div>';
this.jump();
},
/** Place without gliding — a new song, or strip ↔ wheel, lands in one step. */
jump() {
const track = $('.as-track');
track.classList.add('no-anim');
requestAnimationFrame(() => requestAnimationFrame(() => track.classList.remove('no-anim')));
},
render() {
const wheel = this.style() === 'wheel';
shell.classList.toggle('is-wheel', wheel);
shell.classList.toggle('is-playing', moving());
text($('.as-song-name'), state.song ? state.song.name : '');
show($('.as-song-name'), on('showSong') && Boolean(state.song));
this.build();
const cur = state.section?.index ?? -1;
const nxt = state.nextSection?.index ?? -1;
const pills = $('.as-track').children;
for (const el of pills) {
const i = Number(el.dataset.i);
if (Number.isNaN(i)) continue;
el.classList.toggle('is-past', i < cur);
el.classList.toggle('is-current', i === cur);
el.classList.toggle('is-next', i === nxt);
const c = on('sectionColors') ? colorFor(state.sections[i]?.color) || (i === cur ? colorFor(state.section?.color) : null) : null;
if (c) el.style.setProperty('--sect', c);
else el.style.removeProperty('--sect');
el.classList.toggle('has-color', Boolean(c));
}
this.place();
this.frame();
},
place() {
const track = $('.as-track');
const vp = $('.as-viewport');
const cur = Math.max(0, state.section?.index ?? 0);
const pills = [...track.children];
if (this.style() === 'wheel') {
const h = vp.clientHeight / 3.4;
const r = (h / 2) / Math.tan((WHEEL_STEP / 2) * (Math.PI / 180));
track.style.setProperty('--face-h', `${h}px`);
pills.forEach((el, i) => {
const d = i - cur;
el.style.transform = `translateY(-50%) rotateX(${-d * WHEEL_STEP}deg) translateZ(${r}px)`;
el.style.opacity = Math.abs(d) * WHEEL_STEP > 75 ? "0" : String(1 - Math.abs(d) * 0.3);
});
track.style.transform = `translateZ(${-r}px)`;
} else {
pills.forEach((el) => { el.style.transform = ''; el.style.opacity = ''; });
const el = pills[cur] || pills[0];
if (!el) return;
const center = el.offsetLeft + el.offsetWidth / 2;
track.style.transform = `translateX(${vp.clientWidth / 2 - center}px)`;
}
},
frame() {
const cur = state.section?.index ?? -1;
const el = $('.as-track').children[cur];
if (!el) return;
const f = fraction(state.section.start, state.section.end, beatsNow());
el.style.setProperty('--p', on('showProgress') && f != null ? `${f * 100}%` : '0%');
},
toggleStyle() {
const next = this.style() === 'wheel' ? 'strip' : 'wheel';
moduleApi.saveInstanceSettings({ style: next });
this.syncButton();
this.jump();
if (state) this.render();
},
syncButton() {
if (!wheelButton) return;
const wheel = this.style() === 'wheel';
wheelButton.innerHTML = wheel ? ICON_STRIP : ICON_WHEEL;
wheelButton.title = wheel ? 'Strip view' : 'Wheel view';
},
};
/* ── SETLIST ───────────────────────────────────────────────────── */
let listKey = '';
let lastScrolled = -2;
const setlistView = {
render() {
const s = state;
text($('.as-set-name'), s.setlist.name || 'Setlist');
const list = $('.as-list');
const songs = s.setlist.songs;
const key = JSON.stringify([songs, on('showDurations'), on('songColors')]);
if (key !== listKey) {
listKey = key;
lastScrolled = -2;
list.innerHTML = songs.map((x, i) => {
const c = on('songColors') ? colorFor(x.color) : null;
return `<div class="as-row" data-i="${i}"${c ? ` style="--song:${c}"` : ''}>
<span class="as-row-fill"></span>
${c ? '<span class="as-swatch"></span>' : ''}
<span class="as-row-name">${esc(x.name)}</span>
${on('showDurations') ? `<span class="as-row-dur">${x.duration != null ? fmt(x.duration) : ''}</span>` : ''}
</div>`;
}).join('') || '<div class="as-empty">No setlist</div>';
}
const cur = s.song?.index ?? -1;
const nxt = s.nextSong?.index ?? -1;
const q = s.queued?.type === 'song' ? s.queued.songIndex : -1;
for (const row of list.children) {
const i = Number(row.dataset.i);
if (Number.isNaN(i)) continue;
row.classList.toggle('is-past', i < cur);
row.classList.toggle('is-current', i === cur);
row.classList.toggle('is-next', i === nxt && q < 0);
row.classList.toggle('is-queued', i === q);
row.hidden = !on('showPlayed') && i < cur;
}
shell.classList.toggle('is-playing', moving());
if (cur !== lastScrolled && list.children[cur]) {
lastScrolled = cur;
const row = list.children[cur];
list.scrollTo({ top: Math.max(0, row.offsetTop - list.clientHeight * 0.3), behavior: 'smooth' });
}
show($('.as-set-foot'), on('showTotal'));
this.frame();
},
frame() {
const row = $('.as-list').children[state.song?.index ?? -1];
if (row && state.song) {
const f = fraction(state.song.start, state.song.end, beatsNow());
row.style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
const n = songsLeft();
text($('.as-set-foot'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left · ${fmt(setLeft())}`);
},
};
/* ── TRANSPORT ─────────────────────────────────────────────────── */
const transportView = {
render() {
const s = state;
let word = 'Stopped';
let t = 'muted';
if (s.loading) { word = 'Loading'; t = 'warn'; }
else if (!s.live) { word = 'No Live'; t = 'alert'; }
else if (s.recording && s.playing) { word = 'Recording'; t = 'alert'; }
else if (s.countingIn) { word = 'Count-in'; t = 'warn'; }
else if (s.playing && (s.loop.inSection || s.sectionLoop?.mode)) { word = 'Looping'; t = 'warn'; }
else if (s.playing) { word = 'Playing'; t = 'ok'; }
else if (s.paused) { word = 'Paused'; t = 'warn'; }
text($('.as-big'), word);
shell.dataset.tone = t;
const loop = s.sectionLoop?.mode
? `Section loop${s.sectionLoop.count ? ` ×${s.sectionLoop.count}` : ''}`
: s.loop.inSection ? 'Section loop' : '';
text($('.as-loop'), loop);
show($('.as-loop'), on('showLoop') && Boolean(loop));
show($('.as-pos'), on('showPosition'));
this.frame();
},
frame() {
const bb = barBeat();
text($('.as-pos'), bb ? `Bar ${bb.bar} · ${bb.beat}` : '');
},
};
/* ── TEMPO ─────────────────────────────────────────────────────── */
let beatCount = 0;
const tempoView = {
render() {
const s = state;
text($('.as-bpm'), fmtBpm(s.tempo));
fit($('.as-big'));
text($('.as-meter'), s.timeSig ? `${s.timeSig[0]}/${s.timeSig[1]}` : '');
show($('.as-meter'), on('showMeter'));
const n = Math.min(16, Math.max(1, s.timeSig ? s.timeSig[0] : 4));
const beats = $('.as-beats');
if (beatCount !== n) {
beatCount = n;
beats.innerHTML = Array.from({ length: n }, (_, i) => `<i class="${i === 0 ? 'is-down' : ''}"></i>`).join('');
}
show(beats, on('showBeats'));
shell.classList.toggle('is-playing', moving());
this.frame();
},
frame() {
const bb = moving() ? barBeat() : null;
const lights = $('.as-beats').children;
for (let i = 0; i < lights.length; i += 1) lights[i].classList.toggle('is-lit', Boolean(bb) && bb.beat === i + 1);
},
};
/* ── SONG TIME ─────────────────────────────────────────────────── */
const songTimeView = {
render() {
const song = state.song;
text($('.as-caption'), song ? song.name : 'Song');
show($('.as-caption'), on('showSong'));
show($('.as-bar'), on('showProgress') && Boolean(song));
this.frame();
},
frame() {
const left = songLeft();
const dur = state.song?.duration;
const elapsed = dur != null && left != null ? Math.max(0, dur - left) : null;
const mode = prefs().clock || 'remaining';
text($('.as-big'), mode === 'elapsed' ? fmt(elapsed) : `-${fmt(left)}`);
text($('.as-sub-time'), mode === 'both' ? `${fmt(elapsed)} of ${fmt(dur)}` : dur != null ? `of ${fmt(dur)}` : '');
shell.dataset.tone = tone(left) || (moving() ? 'ok' : 'muted');
paintBar($('.as-bar'));
},
};
/* ── SET TIME ──────────────────────────────────────────────────── */
const setTimeView = {
render() {
text($('.as-caption'), state.setlist.name || 'Set');
show($('.as-left-count'), on('showLeft'));
show($('.as-end'), on('showEnd'));
shell.dataset.tone = moving() ? 'ok' : 'muted';
this.frame();
},
frame() {
const left = setLeft();
text($('.as-big'), fmt(left));
const n = songsLeft();
text($('.as-left-count'), n == null ? '' : `${n} song${n === 1 ? '' : 's'} left`);
const end = left != null ? new Date(Date.now() + left * 1000) : null;
text($('.as-end'), end ? `Ends ${end.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}` : '');
},
};
/* ── SECTION ───────────────────────────────────────────────────── */
const sectionView = {
render() {
const sect = state.section;
text($('.as-big'), sect ? sect.name : '—');
const c = on('sectionColors') && sect ? colorFor(sect.color) : null;
songTint(shell, c);
const nx = state.queued?.type === 'section' ? state.queued.section : state.nextSection?.name;
text($('.as-next-sect'), nx ? `→ ${nx}` : '');
show($('.as-next-sect'), on('showNext') && Boolean(nx));
show($('.as-count'), on('showCount'));
show($('.as-sect-bar'), on('showCount'));
this.frame();
},
frame() {
const sect = state.section;
const b = beatsNow();
const looping = Boolean(state.sectionLoop?.mode || state.loop.inSection);
let label = '';
let t = moving() ? 'ok' : 'muted';
if (sect && sect.end != null && b != null) {
const leftBeats = Math.max(0, sect.end - b);
const bpb = beatsPerBar();
const beatLen = 4 / (state.timeSig?.[1] || 4);
if (looping) { label = 'Looping'; t = 'warn'; }
else if (leftBeats <= bpb + 1e-6) {
const n = Math.max(1, Math.ceil(leftBeats / beatLen - 1e-6));
label = `${n} beat${n === 1 ? '' : 's'}`;
if (moving()) t = 'warn';
} else {
const n = Math.ceil(leftBeats / bpb - 1e-6);
label = `${n} bars`;
}
const f = fraction(sect.start, sect.end, b);
$('.as-sect-bar').style.setProperty('--p', `${(f ?? 0) * 100}%`);
}
text($('.as-count'), label);
shell.dataset.tone = t;
},
};
/* ── NEXT SONG ─────────────────────────────────────────────────── */
const nextSongView = {
render() {
const n = state.nextSong;
text($('.as-big'), n ? n.name : 'End of set');
text($('.as-next-dur'), n?.duration != null ? fmt(n.duration) : '');
show($('.as-next-dur'), on('showDuration') && n?.duration != null);
const q = state.queued;
const qText = q ? (q.type === 'section' ? `Queued: ${q.section}${q.song ? ` (${q.song})` : ''}` : `Queued: ${q.song}`) : '';
text($('.as-queued'), qText);
show($('.as-queued'), on('showQueued') && Boolean(q));
shell.dataset.tone = q ? 'warn' : n ? '' : 'muted';
},
frame() {},
};
/* ── STATUS ────────────────────────────────────────────────────── */
const statusView = {
render() {
const s = state;
const rows = [
['AbleSet', s.reachable ? 'Connected' : 'No answer', s.reachable ? 'ok' : 'alert'],
['Ableton Live', !s.reachable ? '—' : s.live ? 'Connected' : 'Not connected', !s.reachable ? 'muted' : s.live ? 'ok' : 'alert'],
];
if (s.loading) rows.push(['Project', 'Loading…', 'warn']);
if (s.recording) rows.push(['Recording', 'On', 'alert']);
if (on('showSetlist')) rows.push(['Setlist', s.setlist.name || '—', s.setlist.name ? '' : 'muted']);
if (on('showAudio')) {
if (!s.audio.length) rows.push(['Audio interface', 'None', 'muted']);
for (const a of s.audio) {
const map = {
armed: ['Armed', 'ok'], sceneA: ['Main (A)', 'ok'], sceneB: ['Backup (B)', 'warn'], alarm: ['Alarm', 'alert'],
};
const [label, t] = map[a.status] || [a.status || 'Connected', ''];
rows.push([a.name, s.reachable ? label : '—', s.reachable ? t : 'muted']);
}
}
$('.as-rows').innerHTML = rows.map(([k, v, t]) => `<div class="as-srow" data-tone="${t}"><i></i><span class="as-sk">${esc(k)}</span><span class="as-sv">${esc(v)}</span></div>`).join('');
const worst = rows.some((r) => r[2] === 'alert') ? 'alert' : rows.some((r) => r[2] === 'warn') ? 'warn' : 'ok';
shell.dataset.tone = worst;
},
frame() {},
};
const VIEW_IMPL = {
now: nowView, sections: sectionsView, setlist: setlistView, transport: transportView, tempo: tempoView,
'song-time': songTimeView, 'set-time': setTimeView, section: sectionView, 'next-song': nextSongView, status: statusView,
};
const impl = VIEW_IMPL[view];
/* ── drawing loop ──────────────────────────────────────────────── */
function render() {
if (!impl || !state) return;
const offline = !state.reachable && view !== 'status';
shell.classList.toggle('is-offline', offline);
show(offlineEl, offline);
if (offline) {
text(offlineEl, !state.configured ? 'Set the AbleSet host in /admin' : 'AbleSet offline');
}
impl.render();
loop();
}
function loop() {
if (raf || !moving()) return;
const step = () => {
raf = 0;
if (!state || !moving()) return;
impl.frame();
raf = requestAnimationFrame(step);
};
raf = requestAnimationFrame(step);
}
function reportStatus() {
if (!state) return moduleApi.setStatus('connecting', 'Connecting…');
if (!state.configured) return moduleApi.setStatus('error', 'No AbleSet host set in /admin');
if (!state.reachable) return moduleApi.setStatus('error', state.lastError ? `AbleSet: ${state.lastError}` : 'Waiting for AbleSet to answer');
if (!state.live) return moduleApi.setStatus('connecting', 'AbleSet is up, but Ableton Live is not connected');
return moduleApi.setStatus('ok', state.setlist.name ? `AbleSet · ${state.setlist.name}` : 'AbleSet');
}
function applyState(next) {
if (!next || typeof next !== 'object') return;
takePos(next.pos);
state = next;
reportStatus();
render();
}
/* The page's one stream is shared, so a tile mounted onto it (a gear
change remounts the tile) joins mid-flow and hears nothing until
AbleSet next changes — which, stopped, may be never. Ask for the state. */
let stopped = false;
async function backfill() {
try {
const res = await moduleApi.fetch('/state');
if (!res.ok) return;
const body = await res.json();
if (!stopped) applyState(body.state);
} catch { /* the stream's error handler reports the outage */ }
}
function takePos(p) {
if (!p) return;
pos = p;
posAt = performance.now();
}
return {
start() {
if (!impl) return;
moduleApi.setStatus('connecting', 'Connecting…');
if (view === 'sections') {
wheelButton = moduleApi.header.addButton({
icon: ICON_WHEEL,
title: 'Wheel view',
onClick: () => sectionsView.toggleStyle(),
});
sectionsView.syncButton();
}
stream = moduleApi.sse('/stream', {
open() { backfill(); },
error() {
moduleApi.setStatus('error', 'Lost the ProdDash stream — reconnecting');
},
events: {
state(e) {
let next;
try { next = JSON.parse(e.data); } catch { return; }
applyState(next);
},
pos(e) {
try { takePos(JSON.parse(e.data)); } catch { return; }
if (state && !moving()) impl.frame();
loop();
},
},
});
backfill();
resizeObserver = new ResizeObserver(() => {
fitAll();
if (view === 'sections' && state) sectionsView.place();
});
resizeObserver.observe(shell);
// Stopped tiles still have a clock to keep honest (Set Time's end time).
tickTimer = setInterval(() => { if (state && !moving()) impl.frame(); }, 1000);
},
stop() {
stopped = true;
if (stream) stream.close();
stream = null;
cancelAnimationFrame(raf);
raf = 0;
clearInterval(tickTimer);
if (resizeObserver) resizeObserver.disconnect();
root.innerHTML = '';
},
};
}
-36
View File
@@ -1,36 +0,0 @@
{
"id": "ableset",
"name": "AbleSet",
"version": "1.0.1",
"proddash": ">=1.5.0",
"description": "The song AbleSet is playing — tempo, time left, sections, setlist and status",
"permissions": [
{
"kind": "local-network",
"reason": "Following AbleSet on this network"
}
],
"provides": ["timers"],
"client": "client.js",
"server": "server.js",
"style": "style.css",
"minSize": {
"w": 1,
"h": 1
},
"defaultSize": {
"w": 8,
"h": 4
},
"configSchema": {
"ableset": {
"type": "endpoint",
"label": "AbleSet",
"default": {
"host": "",
"port": 39051
},
"help": "The Mac running AbleSet (e.g. AB-Tracks.local). 39051 is AbleSet's OSC port; it only changes if someone changed it in AbleSet's settings."
}
}
}
-101
View File
@@ -1,101 +0,0 @@
'use strict';
/**
* The slice of OSC 1.0 AbleSet speaks — messages and bundles with int32,
* float32, string, blob, true/false/nil — encoded and decoded with Buffer
* alone, so the module needs nothing outside Node.
*/
function pad4(n) {
return (n + 3) & ~3;
}
function encodeString(s) {
const raw = Buffer.from(String(s), 'utf8');
const out = Buffer.alloc(pad4(raw.length + 1));
raw.copy(out);
return out;
}
/**
* One message. Arguments are typed by their JS value: integers are int32,
* other numbers float32, booleans T/F, null nil, everything else a string.
*/
function encodeMessage(address, args = []) {
let tags = ',';
const parts = [];
for (const a of args) {
if (a === null || a === undefined) {
tags += 'N';
} else if (typeof a === 'boolean') {
tags += a ? 'T' : 'F';
} else if (typeof a === 'number' && Number.isInteger(a)) {
tags += 'i';
const b = Buffer.alloc(4);
b.writeInt32BE(a);
parts.push(b);
} else if (typeof a === 'number') {
tags += 'f';
const b = Buffer.alloc(4);
b.writeFloatBE(a);
parts.push(b);
} else {
tags += 's';
parts.push(encodeString(a));
}
}
return Buffer.concat([encodeString(address), encodeString(tags), ...parts]);
}
function readString(buf, at) {
let end = at;
while (end < buf.length && buf[end] !== 0) end += 1;
if (end >= buf.length) throw new Error('unterminated OSC string');
return { value: buf.toString('utf8', at, end), next: pad4(end + 1) };
}
function decodeMessage(buf, out) {
const addr = readString(buf, 0);
let at = addr.next;
const args = [];
if (at < buf.length && buf[at] === 0x2c) {
const tagStr = readString(buf, at);
at = tagStr.next;
for (const t of tagStr.value.slice(1)) {
switch (t) {
case 'i': args.push(buf.readInt32BE(at)); at += 4; break;
case 'f': args.push(buf.readFloatBE(at)); at += 4; break;
case 'd': args.push(buf.readDoubleBE(at)); at += 8; break;
case 'h': args.push(Number(buf.readBigInt64BE(at))); at += 8; break;
case 's': case 'S': { const s = readString(buf, at); args.push(s.value); at = s.next; break; }
case 'b': { const n = buf.readInt32BE(at); args.push(buf.subarray(at + 4, at + 4 + n)); at = pad4(at + 4 + n); break; }
case 'T': args.push(true); break;
case 'F': args.push(false); break;
case 'N': args.push(null); break;
case 'I': args.push(Infinity); break;
case 't': at += 8; break;
default: throw new Error(`unsupported OSC type tag "${t}"`);
}
}
}
out.push({ address: addr.value, args });
}
/** Every message in a packet, bundles flattened, in order. */
function decodePacket(buf, out = []) {
if (buf.length >= 16 && buf.toString('ascii', 0, 8) === '#bundle\0') {
let at = 16; // "#bundle\0" + 8-byte timetag
while (at + 4 <= buf.length) {
const n = buf.readInt32BE(at);
at += 4;
if (n <= 0 || at + n > buf.length) break;
decodePacket(buf.subarray(at, at + n), out);
at += n;
}
return out;
}
decodeMessage(buf, out);
return out;
}
module.exports = { encodeMessage, decodePacket };
-719
View File
@@ -1,719 +0,0 @@
'use strict';
/**
* AbleSet — server part.
*
* Owns the one connection to AbleSet: an OSC subscription over UDP (AbleSet
* listens on 39051). `/subscribe auto <port>` makes AbleSet push every value
* that changes to our socket, `/getValues` fills in everything that is
* already true, and AbleSet's `/heartbeat` every 2 s is how we know it is
* still there — when heartbeats stop (AbleSet quit, the Mac slept, the
* network dropped) we keep asking again every few seconds until it answers,
* because a restarted AbleSet has forgotten its subscribers.
*
* The raw values become one view-model, streamed to every tile:
*
* GET /state → { state } (instant paint)
* GET /stream → SSE: `state` on any change, `pos` while playing
* GET /timers(/stream) → song and set countdowns for the Timers module
*
* `pos` carries only what moves every frame (playhead, time left); the
* tiles extrapolate between packets from the tempo, so 5 packets a second
* draws a smooth playhead.
*/
const dgram = require('dgram');
const dns = require('dns');
const { encodeMessage, decodePacket } = require('./osc');
const HEARTBEAT_MS = 15000; // SSE keepalive comment
const SILENT_MS = 5000; // no heartbeat for this long → AbleSet is gone
const RESUBSCRIBE_MS = 3000; // how often to ask again while it is gone
const POS_MS = 200; // playhead packets to tiles while playing
const COALESCE_MS = 40; // AbleSet sends a section change as a burst of addresses
const FINE_MS = 100; // fine-position throttle we ask AbleSet for
/** Things AbleSet can send that no tile draws — kept off the wire. */
const EXCLUDES = ['!/mixer/**', '!/lyrics/**', '!/cues/**', '!/devices/**', '!/settings/**', '!/midi/**', '!/shared/**'];
let current = null;
function emptyState() {
return {
reachable: false,
configured: false,
lastError: '',
live: false,
loading: false,
playing: false,
paused: false,
recording: false,
countingIn: false,
tempo: null,
timeSig: null,
setlist: { name: '', songs: [] },
song: null,
section: null,
nextSection: null,
sections: [],
nextSong: null,
queued: null,
loop: { enabled: false, start: null, end: null, inSection: false },
audio: [],
version: '',
pos: emptyPos(),
};
}
function emptyPos() {
return { beats: null, remainingSong: null, remainingSet: null };
}
module.exports = {
init({ config, log }) {
const ep = config.ableset && typeof config.ableset === 'object' ? config.ableset : {};
const host = String(ep.host || '').trim();
const port = Number(ep.port) || 39051;
const configured = Boolean(host);
const values = new Map(); // OSC address → args, as AbleSet last said
const streams = new Set(); // tile SSE clients
const timerStreams = new Set(); // Timers-module SSE clients
let lastHeard = 0;
let lastAsked = 0;
let resolved = '';
let lastError = '';
let sock = null;
let stopped = false;
let coalesce = null;
let lastFrame = '';
let lastTimersFrame = '';
// Playhead and countdowns, each as "value at a moment" so they can be
// extrapolated to any other moment. AbleSet's time-left values are whole
// seconds; restamping on every one of them makes a countdown stutter, so
// a model is only moved when AbleSet disagrees with it by over a second.
const beatsModel = { value: null, at: 0 };
const songModel = { value: null, at: 0 };
const setModel = { value: null, at: 0 };
const num = (a, i = 0) => {
const v = values.get(a);
const n = v ? Number(v[i]) : NaN;
return v && v[i] !== null && Number.isFinite(n) ? n : null;
};
const str = (a, i = 0) => {
const v = values.get(a);
return v && v[i] != null ? String(v[i]) : '';
};
const arr = (a) => values.get(a) || [];
const flag = (a) => num(a) === 1 || values.get(a)?.[0] === true;
const isPlaying = () => flag('/global/isPlaying');
const tempo = () => num('/global/tempo');
function extrapolate(model, sign) {
if (model.value == null) return null;
if (!isPlaying()) return model.value;
return model.value + sign * (Date.now() - model.at) / 1000;
}
function beatsNow() {
if (beatsModel.value == null) return null;
const t = tempo();
if (!isPlaying() || !t) return beatsModel.value;
return beatsModel.value + ((Date.now() - beatsModel.at) / 1000) * (t / 60);
}
function nudge(model, reported) {
if (reported == null) { model.value = null; return; }
const predicted = extrapolate(model, -1);
if (predicted == null || Math.abs(predicted - reported) > 1.05 || !isPlaying()) {
model.value = reported;
model.at = Date.now();
} else {
// Keep the running model; re-base it so the stamp stays recent.
model.value = predicted;
model.at = Date.now();
}
}
function colorOf(a) {
const v = values.get(a);
if (!v) return null;
const name = v[0] == null ? '' : String(v[0]);
const hex = v[1] == null ? '' : String(v[1]);
return name && name !== 'default' && /^#[0-9a-f]{6}$/i.test(hex) ? hex : null;
}
function part(prefix) {
const name = str(`${prefix}Name`);
if (!name && num(`${prefix}Index`) == null) return null;
const index = num(`${prefix}Index`);
if (index != null && index < 0) return null;
return {
name,
index,
start: num(`${prefix}Start`),
end: num(`${prefix}End`),
duration: num(`${prefix}Duration`),
color: colorOf(`${prefix}Color`),
};
}
function audioInterfaces() {
const out = [];
const names = arr('/audioInterfaces/names');
const statuses = arr('/audioInterfaces/status');
names.forEach((n, i) => {
out.push({ name: String(n || `Interface ${i + 1}`), status: String(statuses[i] || '') });
});
// AbleSet 2 reports its PlayAUDIO12 on its own addresses.
if (!names.length && flag('/playaudio12/isConnected')) {
const scene = num('/playaudio12/scene');
out.push({ name: 'PlayAUDIO12', status: scene === 2 ? 'sceneB' : scene === 1 ? 'sceneA' : '' });
}
return out;
}
function buildState() {
const songs = arr('/setlist/songs');
const durations = arr('/setlist/songDurations');
const colors = arr('/setlist/songColors');
const sectionNames = arr('/setlist/sections');
const sectionColors = arr('/setlist/sectionColors');
const song = part('/setlist/activeSong');
const qIdx = values.get('/setlist/queuedIndex') || [];
const qName = values.get('/setlist/queuedName') || [];
const queuedType = str('/setlist/queuedType');
const ts = values.get('/global/timeSignature');
return {
reachable: isReachable(),
configured,
lastError,
live: flag('/global/isConnected'),
loading: flag('/global/isLoadingFile'),
playing: isPlaying(),
paused: flag('/global/isPaused'),
recording: flag('/global/isRecording'),
countingIn: flag('/setlist/isCountingIn'),
tempo: tempo(),
timeSig: ts && ts.length >= 2 && ts[0] != null ? [Number(ts[0]), Number(ts[1])] : null,
setlist: {
name: str('/setlist/name'),
songs: songs.map((name, i) => ({
name: String(name ?? ''),
duration: durations[i] == null ? null : Number(durations[i]),
color: typeof colors[i] === 'string' && colors[i] !== 'default' ? colors[i] : null,
})),
},
song,
section: part('/setlist/activeSection'),
nextSection: part('/setlist/nextSection'),
sections: sectionNames.map((name, i) => ({
name: String(name ?? ''),
color: typeof sectionColors[i] === 'string' && sectionColors[i] !== 'default' ? sectionColors[i] : null,
})),
sectionLoop: {
mode: str('/setlist/activeSectionLoopMode'),
count: num('/setlist/activeSectionLoopCount'),
},
nextSong: part('/setlist/nextSong'),
queued: queuedType && qIdx[0] != null && Number(qIdx[0]) >= 0
? { type: queuedType, song: String(qName[0] ?? ''), section: String(qName[1] ?? ''), songIndex: Number(qIdx[0]), sectionIndex: qIdx[1] == null ? -1 : Number(qIdx[1]) }
: null,
loop: {
enabled: flag('/setlist/loopEnabled'),
start: num('/setlist/loopStart'),
end: num('/setlist/loopEnd'),
inSection: flag('/setlist/isInActiveSectionLoop'),
},
audio: audioInterfaces(),
version: str('/version/app'),
pos: buildPos(),
};
}
/**
* Time left, as AbleSet counts it. AbleSet only sends these while Live is
* connected; without them the song's length and the playhead still say
* how far there is to go, and the set is the rest of the setlist on top.
*/
function songLeftNow() {
const reported = extrapolate(songModel, -1);
if (reported != null) return reported;
const dur = num('/setlist/activeSongDuration');
const start = num('/setlist/activeSongStart');
const end = num('/setlist/activeSongEnd');
const b = beatsNow();
if (dur == null || start == null || end == null || end <= start || b == null) return null;
return Math.max(0, dur * (1 - Math.min(1, Math.max(0, (b - start) / (end - start)))));
}
function setLeftNow() {
const reported = extrapolate(setModel, -1);
if (reported != null) return reported;
const song = songLeftNow();
const idx = num('/setlist/activeSongIndex');
if (song == null || idx == null) return null;
return song + arr('/setlist/songDurations').slice(idx + 1).reduce((a, d) => a + (Number(d) || 0), 0);
}
function buildPos() {
const round = (v, d) => (v == null ? null : Math.round(v * d) / d);
return {
beats: round(beatsNow(), 1000),
remainingSong: round(songLeftNow(), 100),
remainingSet: round(setLeftNow(), 100),
};
}
function isReachable() {
return lastHeard > 0 && Date.now() - lastHeard < SILENT_MS;
}
/* ── fan-out ─────────────────────────────────────────────────── */
function send(set, payload) {
for (const res of set) {
try { res.write(payload); } catch { set.delete(res); }
}
}
function broadcastState() {
coalesce = null;
const state = buildState();
// Change detection ignores the moving parts, which `pos` carries.
const { pos, ...still } = state;
const frame = JSON.stringify(still);
if (frame !== lastFrame) {
lastFrame = frame;
if (streams.size) send(streams, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
}
broadcastTimers();
}
function scheduleState() {
if (!coalesce) coalesce = setTimeout(broadcastState, COALESCE_MS);
}
function broadcastTimers() {
if (!timerStreams.size) return;
const snap = timersSnapshot();
const { updatedAt, ...rest } = snap;
const key = JSON.stringify(rest.timers.map((t) => [t.state, t.label, t.status, t.targetMs]));
if (key === lastTimersFrame) return;
lastTimersFrame = key;
send(timerStreams, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
}
function timersSnapshot() {
const now = Date.now();
const playing = isPlaying();
const reachable = isReachable();
const song = part('/setlist/activeSong');
const remSong = songLeftNow();
const remSet = setLeftNow();
const state = !reachable ? 'idle' : playing ? 'running' : remSong != null ? 'paused' : 'idle';
const ms = (s) => (s == null ? null : Math.max(0, Math.round(s * 1000)));
const songElapsed = song?.duration != null && remSong != null ? ms(song.duration - remSong) : null;
const songs = arr('/setlist/songs');
const idx = num('/setlist/activeSongIndex');
const left = idx == null ? null : Math.max(0, songs.length - idx - 1);
return {
source: 'ableset',
label: 'AbleSet',
updatedAt: now,
timers: [
{
id: 'song',
label: 'Song',
kind: 'countdown',
state,
startedAt: playing && songElapsed != null ? now - songElapsed : null,
elapsedMs: songElapsed,
remainingMs: ms(remSong),
targetMs: ms(song?.duration),
status: song ? { text: song.name, tone: reachable ? 'ok' : 'muted' } : null,
detail: str('/setlist/activeSectionName') || null,
},
{
id: 'set',
label: 'Set',
kind: 'countdown',
state,
startedAt: null,
elapsedMs: null,
remainingMs: ms(remSet),
targetMs: null,
status: str('/setlist/name') ? { text: str('/setlist/name'), tone: 'muted' } : null,
detail: left == null ? null : `${left} song${left === 1 ? '' : 's'} left`,
},
],
};
}
/* ── OSC in ──────────────────────────────────────────────────── */
const FAST = new Set([
'/global/beatsPosition', '/global/finePosition', '/setlist/activeSongProgress',
'/setlist/activeSectionProgress', '/setlist/remainingTimeInSong', '/setlist/remainingTimeInSet',
'/global/humanPosition', '/global/currentMeasure', '/global/measureOrPosition',
'/global/cpuAverage', '/global/cpuPeak', '/heartbeat', '/subscribed',
]);
function onMessage(m) {
const { address, args } = m;
if (address === '/global/finePosition' || address === '/global/beatsPosition') {
const v = Number(args[0]);
if (Number.isFinite(v)) {
// A whole-beat report while fine ones flow would drag the playhead back.
if (address === '/global/beatsPosition' && values.has('/global/finePosition')
&& Math.abs((beatsNow() ?? v) - v) < 1) return void values.set(address, args);
beatsModel.value = v;
beatsModel.at = Date.now();
}
} else if (address === '/setlist/remainingTimeInSong') {
nudge(songModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/setlist/remainingTimeInSet') {
nudge(setModel, args[0] == null ? null : Number(args[0]));
} else if (address === '/global/isPlaying') {
// Freeze the models at the moment the transport changed.
const b = beatsNow();
if (b != null) { beatsModel.value = b; beatsModel.at = Date.now(); }
for (const mdl of [songModel, setModel]) {
const v = extrapolate(mdl, -1);
if (v != null) { mdl.value = v; mdl.at = Date.now(); }
}
}
values.set(address, args);
if (!FAST.has(address)) scheduleState();
}
/* ── connection ──────────────────────────────────────────────── */
function ask() {
if (!sock || stopped) return;
lastAsked = Date.now();
dns.lookup(host, { family: 4 }, (err, addr) => {
if (stopped || !sock) return;
if (err) {
lastError = `can't find ${host} (${err.code || err.message})`;
scheduleState();
return;
}
resolved = addr;
const myPort = sock.address().port;
const out = (address, args) => {
try {
sock.send(encodeMessage(address, args), port, addr, (e) => {
if (e) lastError = e.message;
});
} catch (e) {
lastError = e.message;
}
};
out('/subscribe', ['auto', myPort, 'ProdDash', FINE_MS, ...EXCLUDES]);
out('/getValues', ['auto', myPort]);
});
}
const watchdog = setInterval(() => {
if (!configured || stopped) return;
const wasReachable = lastHeard > 0 && Date.now() - lastHeard < SILENT_MS + 1000;
if (!isReachable()) {
if (wasReachable) {
log(`no heartbeat from AbleSet at ${host} — asking again`);
lastError = 'AbleSet stopped answering';
scheduleState();
}
if (Date.now() - lastAsked >= RESUBSCRIBE_MS) ask();
}
}, 1000);
watchdog.unref?.();
// While playing, the playhead goes out five times a second. While
// stopped it only goes out when it moved — a jump, a scrub.
let lastPos = '';
const posTimer = setInterval(() => {
if (!streams.size || !isReachable()) return;
const frame = JSON.stringify(buildPos());
if (!isPlaying() && frame === lastPos) return;
lastPos = frame;
send(streams, `event: pos\ndata: ${frame}\n\n`);
}, POS_MS);
posTimer.unref?.();
const keepalive = setInterval(() => {
send(streams, ': ping\n\n');
send(timerStreams, ': ping\n\n');
// The Timers module derives running values itself; a fresh anchor now
// and then keeps a long song honest.
if (timerStreams.size) {
lastTimersFrame = '';
broadcastTimers();
}
}, HEARTBEAT_MS);
keepalive.unref?.();
if (configured) {
sock = dgram.createSocket('udp4');
sock.on('message', (buf, rinfo) => {
if (resolved && rinfo.address !== resolved) return; // only AbleSet talks to us
let msgs;
try {
msgs = decodePacket(buf);
} catch (e) {
log(`unreadable OSC packet from ${rinfo.address}: ${e.message}`);
return;
}
const wasReachable = isReachable();
lastHeard = Date.now();
if (!wasReachable) {
lastError = '';
log(`AbleSet at ${host} (${rinfo.address}) is answering`);
scheduleState();
}
for (const m of msgs) onMessage(m);
});
sock.on('error', (e) => {
lastError = e.message;
log(`OSC socket error: ${e.message}`);
});
sock.bind(0, () => {
log(`following AbleSet at ${host}:${port}`);
ask();
});
} else {
log('no AbleSet host configured — set one in /admin');
}
current = {
streams,
timerStreams,
getState: buildState,
getTimers: timersSnapshot,
};
return {
stop() {
stopped = true;
clearInterval(watchdog);
clearInterval(posTimer);
clearInterval(keepalive);
clearTimeout(coalesce);
if (sock) {
// Be polite: tell AbleSet to stop sending to a port nobody reads.
const s = sock;
sock = null;
try {
if (resolved) s.send(encodeMessage('/unsubscribe', ['auto', s.address().port]), port, resolved, () => { try { s.close(); } catch { /* closed */ } });
else s.close();
} catch {
try { s.close(); } catch { /* closed */ }
}
}
for (const set of [streams, timerStreams]) {
for (const res of set) { try { res.end(); } catch { /* gone */ } }
set.clear();
}
if (current && current.streams === streams) current = null;
},
health() {
if (!configured) return { status: 'error', message: 'No AbleSet host configured' };
if (isReachable()) {
const live = flag('/global/isConnected') ? '' : ' — Ableton Live is not connected';
const set = str('/setlist/name');
return { status: live ? 'connecting' : 'ok', message: `Following ${host}${set ? ` · ${set}` : ''}${live}` };
}
if (lastError) return { status: 'error', message: `AbleSet at ${host}: ${lastError}` };
return { status: 'connecting', message: `Waiting for AbleSet at ${host}:${port} to answer…` };
},
};
},
/**
* One picker entry per tile kind. Each carries only the gear settings its
* tile can act on (guide rules 5–8): four or fewer switches sit loose, the
* Now Playing card's longer list folds into a Show group.
*/
tiles() {
const sw = (label, extra = {}) => ({ type: 'switch', label, default: true, ...extra });
const clock = {
type: 'select',
label: 'Song time',
default: 'remaining',
options: [
{ value: 'remaining', label: 'Time left' },
{ value: 'elapsed', label: 'Elapsed' },
{ value: 'both', label: 'Both' },
],
};
const warnAt = { type: 'number', label: 'Warn at (s)', default: 30, group: 'Colours' };
const alertAt = { type: 'number', label: 'Alert at (s)', default: 10, group: 'Colours' };
return [
{
id: 'now',
name: 'Now Playing',
description: 'The song, its tempo, time left, section, progress and what comes next',
defaultSize: { w: 8, h: 4 },
minSize: { w: 3, h: 2 },
instanceSchema: {
clock,
songColors: sw('Song colours', { default: false }),
showTempo: sw('Tempo', { group: 'Show' }),
showMeter: sw('Time signature', { group: 'Show' }),
showDuration: sw('Duration', { group: 'Show' }),
showClock: sw('Song time', { group: 'Show' }),
showSection: sw('Section', { group: 'Show' }),
showProgress: sw('Progress bar', { group: 'Show' }),
showNext: sw('Next song', { group: 'Show' }),
showLeft: sw('Songs left', { group: 'Show' }),
warnAt,
alertAt,
},
instanceGroups: { Show: { collapsed: true, columns: 2 }, Colours: { collapsed: true, columns: 2 } },
},
{
id: 'sections',
name: 'Song Sections',
description: 'Where the song is — its sections sliding past, as a strip or a wheel',
defaultSize: { w: 8, h: 2 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showProgress: sw('Section progress'),
showSong: sw('Song name', { default: false }),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'setlist',
name: 'Setlist',
description: 'Every song in the set, played, playing and to come',
defaultSize: { w: 3, h: 6 },
minSize: { w: 2, h: 2 },
instanceSchema: {
showDurations: sw('Durations'),
showPlayed: sw('Played songs'),
showTotal: sw('Time left'),
songColors: sw('Song colours', { default: false }),
},
},
{
id: 'transport',
name: 'Transport',
description: 'Playing, stopped, counting in or looping, in colour',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showPosition: sw('Bar and beat'),
showLoop: sw('Loop'),
},
},
{
id: 'tempo',
name: 'Tempo',
description: 'BPM and time signature, with a light on every beat',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showMeter: sw('Time signature'),
showBeats: sw('Beat lights'),
},
},
{
id: 'song-time',
name: 'Song Time',
description: 'Time left in the song, turning amber then red near the end',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
clock,
showSong: sw('Song name'),
showProgress: sw('Progress bar'),
warnAt,
alertAt,
},
instanceGroups: { Colours: { collapsed: true, columns: 2 } },
},
{
id: 'set-time',
name: 'Set Time',
description: 'Time left in the whole set and how many songs remain',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showLeft: sw('Songs left'),
showEnd: sw('End time'),
},
},
{
id: 'section',
name: 'Section',
description: 'The section playing now, what is next, and the count to the change',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showNext: sw('Next section'),
showCount: sw('Countdown'),
sectionColors: sw('Section colours', { default: false }),
},
},
{
id: 'next-song',
name: 'Next Song',
description: 'The song after this one, and anything queued',
defaultSize: { w: 3, h: 2 },
minSize: { w: 1, h: 1 },
instanceSchema: {
showDuration: sw('Duration'),
showQueued: sw('Queued'),
},
},
{
id: 'status',
name: 'AbleSet Status',
description: 'AbleSet, Ableton Live and audio interface health at a glance',
defaultSize: { w: 3, h: 3 },
minSize: { w: 2, h: 1 },
instanceSchema: {
showAudio: sw('Audio interfaces'),
showSetlist: sw('Setlist name'),
},
},
];
},
routes() {
const sendJson = (res, code, body) => {
res.writeHead(code, { 'Content-Type': 'application/json; charset=utf-8', 'Cache-Control': 'no-store' });
res.end(JSON.stringify(body));
};
const openSse = (req, res, set, first) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream; charset=utf-8',
'Cache-Control': 'no-cache, no-transform',
Connection: 'keep-alive',
'X-Accel-Buffering': 'no',
});
res.write('retry: 3000\n\n');
res.write(first);
if (!set) return void res.end();
try { req.socket.setKeepAlive(true, 15000); } catch { /* gone */ }
set.add(res);
req.on('close', () => set.delete(res));
};
return {
'GET /state': (req, res) => sendJson(res, 200, { state: current ? current.getState() : emptyState() }),
'GET /stream': (req, res) => {
const state = current ? current.getState() : emptyState();
openSse(req, res, current ? current.streams : null, `event: state\ndata: ${JSON.stringify(state)}\n\n`);
},
'GET /timers': (req, res) => sendJson(res, 200, current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] }),
'GET /timers/stream': (req, res) => {
const snap = current ? current.getTimers() : { source: 'ableset', label: 'AbleSet', updatedAt: Date.now(), timers: [] };
openSse(req, res, current ? current.timerStreams : null, `event: timers\ndata: ${JSON.stringify(snap)}\n\n`);
},
};
},
};
-416
View File
@@ -1,416 +0,0 @@
/* AbleSet tiles. Every size hangs off the tile itself (container units), so
a tile reads the same at 2×1 on a laptop as at 12×6 on a wall. Colours are
the shell's variables; a tone (ok / warn / alert / muted) picks among them,
and AbleSet's own song or section colour arrives as --song when a tile is
set to use it. */
[data-module="ableset"] .as-root {
--tone: var(--text);
--live: var(--accent);
position: absolute;
inset: 0;
container-type: size;
overflow: hidden;
user-select: none;
-webkit-user-select: none;
color: var(--text);
font-variant-numeric: tabular-nums;
}
[data-module="ableset"] .as-root.has-song-color { --live: var(--song); }
[data-module="ableset"] .as-root[data-tone="ok"] { --tone: var(--accent); }
[data-module="ableset"] .as-root[data-tone="warn"] { --tone: var(--warn); }
[data-module="ableset"] .as-root[data-tone="alert"] { --tone: var(--danger); }
[data-module="ableset"] .as-root[data-tone="muted"] { --tone: var(--muted); }
[data-module="ableset"] .as-body {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
align-items: stretch;
justify-content: center;
padding: clamp(4px, 4cqmin, 28px);
gap: clamp(2px, 2.5cqh, 18px);
min-width: 0;
}
[data-module="ableset"] [hidden] { display: none !important; }
[data-module="ableset"] .as-offline {
position: absolute;
top: clamp(3px, 3cqmin, 14px);
right: clamp(4px, 4cqmin, 18px);
z-index: 2;
font-size: clamp(9px, 6cqmin, 15px);
font-weight: 700;
color: var(--danger);
text-transform: uppercase;
letter-spacing: 0.04em;
}
[data-module="ableset"] .as-root.is-offline .as-body { opacity: 0.35; filter: grayscale(0.7); }
[data-module="ableset"] [data-fit] {
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── the song bar: played sections · this section · the playhead ── */
[data-module="ableset"] .as-bar {
--done: 0%;
--sect-l: 0%;
--sect-w: 0%;
--played: 0%;
position: relative;
flex-shrink: 0;
height: clamp(4px, 5.5cqh, 26px);
border-radius: 999px;
overflow: hidden;
background: color-mix(in srgb, var(--live) 14%, var(--panel));
}
[data-module="ableset"] .as-bar-done {
position: absolute;
inset: 0 auto 0 0;
width: var(--done);
background: color-mix(in srgb, var(--live) 32%, var(--panel));
}
[data-module="ableset"] .as-bar-sect {
position: absolute;
top: 0;
bottom: 0;
left: var(--sect-l);
width: var(--sect-w);
background: color-mix(in srgb, var(--live) 55%, var(--panel));
overflow: hidden;
}
[data-module="ableset"] .as-bar-played {
position: absolute;
inset: 0 auto 0 0;
width: var(--played);
background: linear-gradient(90deg,
color-mix(in srgb, var(--live) 70%, var(--panel)) 0%,
color-mix(in srgb, var(--live) 70%, var(--panel)) calc(100% - 3.5cqw),
color-mix(in srgb, var(--live) 70%, var(--text)) 100%);
}
/* ── NOW PLAYING ─────────────────────────────────────────────────── */
[data-module="ableset"] .as-now .as-body {
align-items: center;
padding-inline: clamp(6px, 7cqw, 90px);
gap: clamp(3px, 4cqh, 30px);
}
[data-module="ableset"] .as-now .as-title-box,
[data-module="ableset"] .as-now .as-next-box { width: 100%; display: flex; justify-content: center; min-width: 0; }
[data-module="ableset"] .as-now .as-title {
font-size: clamp(14px, 20cqh, 170px);
font-weight: 800;
line-height: 1.05;
letter-spacing: -0.01em;
color: var(--text);
transition: color 0.3s;
}
[data-module="ableset"] .as-now.is-playing .as-title { color: var(--live); }
[data-module="ableset"] .as-now .as-bar { width: 100%; }
[data-module="ableset"] .as-badges {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: clamp(3px, 1.8cqh, 16px);
max-width: 100%;
}
[data-module="ableset"] .as-badge {
font-size: clamp(9px, 7cqh, 44px);
font-weight: 700;
line-height: 1;
padding: 0.28em 0.5em;
border-radius: 0.22em;
background: color-mix(in srgb, var(--text) 12%, var(--panel));
color: color-mix(in srgb, var(--text) 80%, var(--accent));
white-space: nowrap;
}
[data-module="ableset"] .as-badge.b-section {
color: var(--text);
text-transform: uppercase;
background: color-mix(in srgb, var(--text) 7%, var(--panel));
}
[data-module="ableset"] .as-badge.b-clock[data-tone="warn"] { color: var(--accent-text); background: var(--warn); }
[data-module="ableset"] .as-badge.b-clock[data-tone="alert"] { color: var(--accent-text); background: var(--danger); }
[data-module="ableset"] .as-next {
display: flex;
flex-direction: column;
align-items: center;
width: 100%;
min-width: 0;
gap: clamp(1px, 0.6cqh, 6px);
}
[data-module="ableset"] .as-next-label {
font-size: clamp(8px, 5cqh, 30px);
font-weight: 700;
color: color-mix(in srgb, var(--live) 55%, var(--muted));
}
[data-module="ableset"] .as-next.is-queued .as-next-label { color: var(--warn); }
[data-module="ableset"] .as-next-title {
font-size: clamp(11px, 12cqh, 90px);
font-weight: 500;
line-height: 1.1;
color: color-mix(in srgb, var(--live) 45%, var(--muted));
}
[data-module="ableset"] .as-left {
font-size: clamp(8px, 6cqh, 36px);
color: color-mix(in srgb, var(--live) 40%, var(--muted));
}
/* ── SONG SECTIONS ──────────────────────────────────────────────── */
[data-module="ableset"] .as-sections .as-body { padding: clamp(3px, 3cqmin, 16px) 0; gap: clamp(2px, 2cqh, 10px); }
[data-module="ableset"] .as-sections .as-song-name {
flex-shrink: 0;
text-align: center;
padding: 0 clamp(4px, 3cqw, 20px);
font-size: clamp(9px, 13cqh, 28px);
font-weight: 700;
color: var(--muted);
}
[data-module="ableset"] .as-viewport {
position: relative;
flex: 1;
min-height: 0;
overflow: hidden;
-webkit-mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
mask-image: linear-gradient(90deg, transparent, #000 12%, #000 88%, transparent);
}
[data-module="ableset"] .as-track {
position: absolute;
top: 0;
bottom: 0;
left: 0;
display: flex;
align-items: center;
gap: clamp(4px, 2cqw, 18px);
transition: transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
will-change: transform;
}
[data-module="ableset"] .as-track.no-anim,
[data-module="ableset"] .as-track.no-anim .as-pill { transition: none !important; }
[data-module="ableset"] .as-pill {
--p: 0%;
--sect: var(--accent);
position: relative;
flex-shrink: 0;
display: flex;
align-items: center;
height: min(78%, 22cqw);
padding: 0 0.8em;
border-radius: 0.35em;
font-size: clamp(9px, 26cqh, 64px);
font-weight: 700;
line-height: 1;
white-space: nowrap;
overflow: hidden;
color: var(--muted);
background: color-mix(in srgb, var(--text) 8%, var(--panel));
border: 2px solid transparent;
transition: background 0.4s, color 0.4s, border-color 0.4s, opacity 0.5s, transform 0.65s cubic-bezier(0.22, 0.9, 0.3, 1);
}
[data-module="ableset"] .as-pill.has-color { background: color-mix(in srgb, var(--sect) 22%, var(--panel)); }
[data-module="ableset"] .as-pill.is-past { opacity: 0.45; }
[data-module="ableset"] .as-pill.is-next { border-color: color-mix(in srgb, var(--sect) 60%, transparent); color: var(--text); }
[data-module="ableset"] .as-pill.is-current {
color: var(--accent-text);
background: color-mix(in srgb, var(--sect) 55%, var(--panel));
}
[data-module="ableset"] .as-pill-fill {
position: absolute;
inset: 0 auto 0 0;
width: 0;
background: var(--sect);
}
[data-module="ableset"] .as-pill.is-current .as-pill-fill { width: var(--p); }
[data-module="ableset"] .as-pill-text { position: relative; }
[data-module="ableset"] .as-pill.is-empty { color: var(--muted); }
/* the lottery wheel: faces on a drum turning towards the viewer */
[data-module="ableset"] .is-wheel .as-viewport {
perspective: 900px;
-webkit-mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
mask-image: linear-gradient(180deg, transparent, #000 22%, #000 78%, transparent);
}
[data-module="ableset"] .is-wheel .as-track {
--face-h: 30px;
inset: 50% 0 auto 0;
display: block;
height: 0;
transform-style: preserve-3d;
transition: none;
}
[data-module="ableset"] .is-wheel .as-pill {
position: absolute;
left: 50%;
top: 0;
translate: -50% 0;
height: var(--face-h);
justify-content: center;
min-width: 55%;
max-width: 94%;
font-size: clamp(9px, calc(var(--face-h) * 0.55), 72px);
backface-visibility: hidden;
-webkit-backface-visibility: hidden;
}
/* ── SETLIST ─────────────────────────────────────────────────────── */
[data-module="ableset"] .as-setlist .as-body { justify-content: flex-start; gap: 6px; padding: 8px; }
[data-module="ableset"] .as-set-head {
flex-shrink: 0;
font-size: 0.8rem;
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
[data-module="ableset"] .as-list { flex: 1; min-height: 0; overflow-y: auto; display: flex; flex-direction: column; gap: 3px; scrollbar-width: thin; }
[data-module="ableset"] .as-row {
--p: 0%;
--song: var(--accent);
position: relative;
display: flex;
align-items: center;
gap: 0.5em;
padding: 0.35em 0.55em;
border-radius: 6px;
font-size: clamp(11px, 5.5cqw, 19px);
background: color-mix(in srgb, var(--text) 5%, transparent);
overflow: hidden;
flex-shrink: 0;
}
[data-module="ableset"] .as-row-fill { position: absolute; inset: 0 auto 0 0; width: 0; background: color-mix(in srgb, var(--accent) 22%, transparent); }
[data-module="ableset"] .as-row.is-current .as-row-fill { width: var(--p); }
[data-module="ableset"] .as-row-name { position: relative; flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
[data-module="ableset"] .as-row-dur { position: relative; color: var(--muted); font-size: 0.85em; }
[data-module="ableset"] .as-swatch { position: relative; width: 0.55em; height: 0.55em; border-radius: 50%; background: var(--song); flex-shrink: 0; }
[data-module="ableset"] .as-row.is-past { opacity: 0.4; }
[data-module="ableset"] .as-row.is-current { font-weight: 700; color: var(--accent); background: color-mix(in srgb, var(--accent) 10%, transparent); box-shadow: inset 3px 0 0 var(--accent); }
[data-module="ableset"] .as-row.is-next { box-shadow: inset 3px 0 0 color-mix(in srgb, var(--text) 40%, transparent); }
[data-module="ableset"] .as-row.is-queued { box-shadow: inset 3px 0 0 var(--warn); color: var(--warn); }
[data-module="ableset"] .as-set-foot { flex-shrink: 0; font-size: 0.85rem; color: var(--muted); text-align: right; }
[data-module="ableset"] .as-empty { color: var(--muted); padding: 1em; text-align: center; }
/* ── single readouts: transport, tempo, times, section, next ────── */
[data-module="ableset"] .as-big-box { display: flex; justify-content: center; min-width: 0; width: 100%; }
[data-module="ableset"] .as-big {
font-size: clamp(14px, 38cqh, 220px);
font-weight: 800;
line-height: 1;
color: var(--tone);
text-align: center;
transition: color 0.3s;
}
[data-module="ableset"] .as-big small { font-size: 0.35em; font-weight: 700; color: var(--muted); }
[data-module="ableset"] .as-caption {
text-align: center;
font-size: clamp(8px, 12cqh, 30px);
font-weight: 700;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.05em;
}
[data-module="ableset"] .as-sub {
display: flex;
justify-content: center;
flex-wrap: wrap;
gap: 0.25em 1em;
font-size: clamp(8px, 12cqh, 34px);
color: var(--muted);
text-align: center;
white-space: nowrap;
}
[data-module="ableset"] .as-sub:empty { display: none; }
/* A tone washes the tile's background, so a wall of them reads at a glance. */
[data-module="ableset"] .as-transport,
[data-module="ableset"] .as-song-time,
[data-module="ableset"] .as-section,
[data-module="ableset"] .as-status,
[data-module="ableset"] .as-next-song {
background: color-mix(in srgb, var(--tone) 9%, transparent);
transition: background 0.3s;
}
[data-module="ableset"] .as-root[data-tone="muted"],
[data-module="ableset"] .as-root[data-tone=""] { background: none; }
[data-module="ableset"] .as-transport .as-big { text-transform: uppercase; font-size: clamp(12px, 34cqh, 180px); }
[data-module="ableset"] .as-transport .as-loop { color: var(--warn); font-weight: 700; }
[data-module="ableset"] .as-tempo .as-big { color: var(--text); }
[data-module="ableset"] .as-beats { display: flex; justify-content: center; gap: clamp(3px, 3cqw, 14px); flex-shrink: 0; }
[data-module="ableset"] .as-beats i {
width: clamp(6px, 11cqh, 30px);
aspect-ratio: 1;
border-radius: 50%;
background: color-mix(in srgb, var(--text) 14%, var(--panel));
transition: background 0.08s;
}
[data-module="ableset"] .as-beats i.is-lit { background: var(--text); }
[data-module="ableset"] .as-beats i.is-down.is-lit { background: var(--accent); }
[data-module="ableset"] .as-song-time .as-bar { height: clamp(3px, 5cqh, 14px); }
[data-module="ableset"] .as-section .as-big { text-transform: uppercase; }
[data-module="ableset"] .as-section.has-song-color .as-big { color: var(--song); }
[data-module="ableset"] .as-section .as-count { color: var(--tone); font-weight: 700; }
[data-module="ableset"] .as-sect-bar {
--p: 0%;
height: clamp(3px, 4cqh, 12px);
border-radius: 999px;
background: color-mix(in srgb, var(--text) 12%, var(--panel));
overflow: hidden;
flex-shrink: 0;
}
[data-module="ableset"] .as-sect-bar > div { height: 100%; width: var(--p); background: var(--tone); }
[data-module="ableset"] .as-next-song .as-big { color: var(--text); font-size: clamp(12px, 30cqh, 150px); }
[data-module="ableset"] .as-queued {
align-self: center;
font-size: clamp(8px, 11cqh, 28px);
font-weight: 700;
padding: 0.2em 0.6em;
border-radius: 0.3em;
color: var(--accent-text);
background: var(--warn);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
max-width: 100%;
}
/* ── STATUS ──────────────────────────────────────────────────────── */
[data-module="ableset"] .as-status .as-body { justify-content: flex-start; padding: 8px 10px; }
[data-module="ableset"] .as-rows { display: flex; flex-direction: column; gap: 4px; }
[data-module="ableset"] .as-srow {
--dot: var(--muted);
display: flex;
align-items: center;
gap: 0.55em;
font-size: clamp(11px, 4.5cqw, 18px);
padding: 0.3em 0.5em;
border-radius: 6px;
background: color-mix(in srgb, var(--dot) 10%, transparent);
}
[data-module="ableset"] .as-srow[data-tone="ok"] { --dot: var(--accent); }
[data-module="ableset"] .as-srow[data-tone="warn"] { --dot: var(--warn); }
[data-module="ableset"] .as-srow[data-tone="alert"] { --dot: var(--danger); }
[data-module="ableset"] .as-srow[data-tone=""] { --dot: var(--text); background: color-mix(in srgb, var(--text) 5%, transparent); }
[data-module="ableset"] .as-srow i { width: 0.6em; height: 0.6em; border-radius: 50%; background: var(--dot); flex-shrink: 0; }
[data-module="ableset"] .as-sk { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
[data-module="ableset"] .as-sv { font-weight: 700; color: var(--dot); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 60%; }
[data-module="ableset"] .as-srow[data-tone=""] .as-sv { color: var(--text); }
-198
View File
@@ -1,198 +0,0 @@
#!/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}×` : ''}`));