Fix live stream buffering and add websocket status
This commit is contained in:
24
package-lock.json
generated
24
package-lock.json
generated
@@ -8,7 +8,8 @@
|
|||||||
"name": "arcana-fx",
|
"name": "arcana-fx",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2",
|
||||||
|
"ws": "^8.18.0"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
@@ -825,6 +826,27 @@
|
|||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.8"
|
"node": ">= 0.8"
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"node_modules/ws": {
|
||||||
|
"version": "8.20.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/ws/-/ws-8.20.1.tgz",
|
||||||
|
"integrity": "sha512-It4dO0K5v//JtTXuPkfEOaI3uUN87iYPnqo/ZzqCoG3g8uhA66QUMs/SrM0YK7/NAu+r4LMh/9dq2A7k+rHs+w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=10.0.0"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"bufferutil": "^4.0.1",
|
||||||
|
"utf-8-validate": ">=5.0.2"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"bufferutil": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"utf-8-validate": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,13 @@
|
|||||||
"scripts": {
|
"scripts": {
|
||||||
"start": "node src/server.js",
|
"start": "node src/server.js",
|
||||||
"dev": "node --watch src/server.js",
|
"dev": "node --watch src/server.js",
|
||||||
"check": "node --check src/server.js && node --check src/filestash.js && node --check src/effects.js && node --check src/homeassistant.js && node --check src/streamMixer.js && node --check src/routes.js"
|
"check": "node --check src/server.js && node --check src/filestash.js && node --check src/effects.js && node --check src/homeassistant.js && node --check src/streamMixer.js && node --check src/routes.js && node --check src/wsHub.js"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=20"
|
"node": ">=20"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"express": "^4.19.2"
|
"express": "^4.19.2",
|
||||||
|
"ws": "^8.18.0"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -46,6 +46,10 @@
|
|||||||
busy: false,
|
busy: false,
|
||||||
streamOn: false,
|
streamOn: false,
|
||||||
active: [],
|
active: [],
|
||||||
|
statusSocket: null,
|
||||||
|
statusFallbackTimer: null,
|
||||||
|
statusReconnectTimer: null,
|
||||||
|
statusReconnectDelay: 500,
|
||||||
sets: [],
|
sets: [],
|
||||||
currentSetId: null,
|
currentSetId: null,
|
||||||
lastVariantPerGroup: new Map(), // anti-immediate-repeat memory
|
lastVariantPerGroup: new Map(), // anti-immediate-repeat memory
|
||||||
@@ -354,9 +358,65 @@
|
|||||||
async function refreshActive() {
|
async function refreshActive() {
|
||||||
try {
|
try {
|
||||||
const res = await fetchJson('/api/stream/status');
|
const res = await fetchJson('/api/stream/status');
|
||||||
state.active = (res.stream && res.stream.active) || [];
|
applyStreamStatus(res.stream);
|
||||||
renderActive();
|
} catch { /* status fallback is best-effort */ }
|
||||||
} catch { /* status polling is best-effort */ }
|
}
|
||||||
|
|
||||||
|
function applyStreamStatus(stream) {
|
||||||
|
if (!stream) return;
|
||||||
|
state.active = stream.active || [];
|
||||||
|
renderActive();
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusWsUrl() {
|
||||||
|
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
|
||||||
|
return `${proto}//${window.location.host}/ws/status`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopStatusFallback() {
|
||||||
|
if (state.statusFallbackTimer) clearInterval(state.statusFallbackTimer);
|
||||||
|
state.statusFallbackTimer = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function startStatusFallback() {
|
||||||
|
if (state.statusFallbackTimer) return;
|
||||||
|
refreshActive();
|
||||||
|
state.statusFallbackTimer = setInterval(refreshActive, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function connectStatusSocket() {
|
||||||
|
if (state.statusSocket && (
|
||||||
|
state.statusSocket.readyState === WebSocket.OPEN ||
|
||||||
|
state.statusSocket.readyState === WebSocket.CONNECTING
|
||||||
|
)) return;
|
||||||
|
|
||||||
|
const ws = new WebSocket(statusWsUrl());
|
||||||
|
state.statusSocket = ws;
|
||||||
|
|
||||||
|
ws.addEventListener('open', () => {
|
||||||
|
state.statusReconnectDelay = 500;
|
||||||
|
stopStatusFallback();
|
||||||
|
ws.send(JSON.stringify({ type: 'getStatus' }));
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('message', (event) => {
|
||||||
|
let msg = null;
|
||||||
|
try { msg = JSON.parse(event.data); } catch { return; }
|
||||||
|
if (msg && msg.type === 'streamStatus') applyStreamStatus(msg.stream);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('close', () => {
|
||||||
|
if (state.statusSocket === ws) state.statusSocket = null;
|
||||||
|
startStatusFallback();
|
||||||
|
clearTimeout(state.statusReconnectTimer);
|
||||||
|
const delay = state.statusReconnectDelay;
|
||||||
|
state.statusReconnectDelay = Math.min(10000, Math.round(state.statusReconnectDelay * 1.7));
|
||||||
|
state.statusReconnectTimer = setTimeout(connectStatusSocket, delay);
|
||||||
|
});
|
||||||
|
|
||||||
|
ws.addEventListener('error', () => {
|
||||||
|
try { ws.close(); } catch { /* noop */ }
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSet(groupId) {
|
function toggleSet(groupId) {
|
||||||
@@ -453,5 +513,5 @@
|
|||||||
loadEffects();
|
loadEffects();
|
||||||
renderSetsSelect();
|
renderSetsSelect();
|
||||||
renderActive();
|
renderActive();
|
||||||
setInterval(refreshActive, 1500);
|
connectStatusSocket();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ const { EffectsService } = require('./effects');
|
|||||||
const { HomeAssistantClient } = require('./homeassistant');
|
const { HomeAssistantClient } = require('./homeassistant');
|
||||||
const { StreamMixer } = require('./streamMixer');
|
const { StreamMixer } = require('./streamMixer');
|
||||||
const { createApiRouter } = require('./routes');
|
const { createApiRouter } = require('./routes');
|
||||||
|
const { attachStatusWebSocket } = require('./wsHub');
|
||||||
|
|
||||||
function buildApp(config) {
|
function buildApp(config) {
|
||||||
const filestash = new FilestashClient({
|
const filestash = new FilestashClient({
|
||||||
@@ -93,7 +94,7 @@ function buildApp(config) {
|
|||||||
|
|
||||||
function main() {
|
function main() {
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const { app, effects } = buildApp(config);
|
const { app, effects, mixer } = buildApp(config);
|
||||||
|
|
||||||
const server = app.listen(config.env.port, () => {
|
const server = app.listen(config.env.port, () => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -108,6 +109,12 @@ function main() {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const wsHub = attachStatusWebSocket({
|
||||||
|
server,
|
||||||
|
mixer,
|
||||||
|
entity: config.homeAssistant.allowedMediaPlayer,
|
||||||
|
});
|
||||||
|
|
||||||
// Warm the cache in the background; ignore failures, they will surface on /api/effects.
|
// Warm the cache in the background; ignore failures, they will surface on /api/effects.
|
||||||
effects.list().catch((err) => {
|
effects.list().catch((err) => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
@@ -118,6 +125,7 @@ function main() {
|
|||||||
process.on(sig, () => {
|
process.on(sig, () => {
|
||||||
// eslint-disable-next-line no-console
|
// eslint-disable-next-line no-console
|
||||||
console.log(`Received ${sig}, shutting down.`);
|
console.log(`Received ${sig}, shutting down.`);
|
||||||
|
wsHub.close();
|
||||||
server.close(() => process.exit(0));
|
server.close(() => process.exit(0));
|
||||||
setTimeout(() => process.exit(1), 5000).unref();
|
setTimeout(() => process.exit(1), 5000).unref();
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
|
|
||||||
|
const EventEmitter = require('node:events');
|
||||||
const { spawn } = require('node:child_process');
|
const { spawn } = require('node:child_process');
|
||||||
const crypto = require('node:crypto');
|
const crypto = require('node:crypto');
|
||||||
|
|
||||||
@@ -11,8 +12,17 @@ const FRAME_BYTES = Math.floor(SAMPLE_RATE * CHANNELS * BYTES_PER_SAMPLE * FRAME
|
|||||||
const MAX_ACTIVE = 24;
|
const MAX_ACTIVE = 24;
|
||||||
const MAX_QUEUE_BYTES = FRAME_BYTES * 250; // 5 seconds of decoded audio buffering per effect.
|
const MAX_QUEUE_BYTES = FRAME_BYTES * 250; // 5 seconds of decoded audio buffering per effect.
|
||||||
|
|
||||||
class StreamMixer {
|
// Drop a frame for any client whose response socket has more than ~60ms
|
||||||
|
// (3 frames) of audio still queued in Node's writable buffer. Real-time
|
||||||
|
// soundboard contract: the live edge always advances; if the player is
|
||||||
|
// slow we shed silence/mix frames instead of letting the TCP buffer grow
|
||||||
|
// into seconds of stale audio. That growth is what caused button-to-sound
|
||||||
|
// latency to drift upward the longer the device stream ran.
|
||||||
|
const MAX_CLIENT_BUFFER_BYTES = FRAME_BYTES * 3;
|
||||||
|
|
||||||
|
class StreamMixer extends EventEmitter {
|
||||||
constructor({ effects, localMediaBaseUrl }) {
|
constructor({ effects, localMediaBaseUrl }) {
|
||||||
|
super();
|
||||||
if (!effects) throw new Error('StreamMixer requires effects');
|
if (!effects) throw new Error('StreamMixer requires effects');
|
||||||
if (!localMediaBaseUrl) throw new Error('StreamMixer requires localMediaBaseUrl');
|
if (!localMediaBaseUrl) throw new Error('StreamMixer requires localMediaBaseUrl');
|
||||||
this.effects = effects;
|
this.effects = effects;
|
||||||
@@ -21,6 +31,8 @@ class StreamMixer {
|
|||||||
this.active = new Map();
|
this.active = new Map();
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
this.sequence = 0;
|
this.sequence = 0;
|
||||||
|
this.nextFrameAt = 0;
|
||||||
|
this.setMaxListeners(64);
|
||||||
}
|
}
|
||||||
|
|
||||||
stream(res) {
|
stream(res) {
|
||||||
@@ -30,15 +42,27 @@ class StreamMixer {
|
|||||||
res.setHeader('Pragma', 'no-cache');
|
res.setHeader('Pragma', 'no-cache');
|
||||||
res.setHeader('Expires', '0');
|
res.setHeader('Expires', '0');
|
||||||
res.setHeader('Connection', 'keep-alive');
|
res.setHeader('Connection', 'keep-alive');
|
||||||
|
// Disable Nagle: 20ms PCM frames should be flushed immediately rather
|
||||||
|
// than coalesced by the kernel, which would add a frame or two of jitter.
|
||||||
|
if (res.socket && typeof res.socket.setNoDelay === 'function') {
|
||||||
|
try { res.socket.setNoDelay(true); } catch { /* noop */ }
|
||||||
|
}
|
||||||
res.write(wavHeader());
|
res.write(wavHeader());
|
||||||
|
|
||||||
const client = { res, connectedAt: Date.now(), mutedUntil: Date.now() + 80 };
|
const client = {
|
||||||
|
res,
|
||||||
|
connectedAt: Date.now(),
|
||||||
|
mutedUntil: Date.now() + 80,
|
||||||
|
droppedFrames: 0,
|
||||||
|
};
|
||||||
this.clients.add(client);
|
this.clients.add(client);
|
||||||
this._ensureTimer();
|
this._ensureTimer();
|
||||||
|
this.emit('change', this._reason('clientConnect'));
|
||||||
|
|
||||||
res.on('close', () => {
|
res.on('close', () => {
|
||||||
this.clients.delete(client);
|
const had = this.clients.delete(client);
|
||||||
this._maybeStopTimer();
|
this._maybeStopTimer();
|
||||||
|
if (had) this.emit('change', this._reason('clientDisconnect'));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,17 +126,21 @@ class StreamMixer {
|
|||||||
});
|
});
|
||||||
|
|
||||||
proc.on('close', (code) => {
|
proc.on('close', (code) => {
|
||||||
|
const wasActive = this.active.has(id);
|
||||||
item.ended = true;
|
item.ended = true;
|
||||||
if (code && code !== 0 && !item.error) item.error = `ffmpeg exited ${code}`;
|
if (code && code !== 0 && !item.error) item.error = `ffmpeg exited ${code}`;
|
||||||
|
if (wasActive) this.emit('change', this._reason('end', { id, error: item.error || null }));
|
||||||
});
|
});
|
||||||
|
|
||||||
proc.on('error', (err) => {
|
proc.on('error', (err) => {
|
||||||
item.error = err.message;
|
item.error = err.message;
|
||||||
item.ended = true;
|
item.ended = true;
|
||||||
|
this.emit('change', this._reason('error', { id, error: err.message }));
|
||||||
});
|
});
|
||||||
|
|
||||||
this.active.set(id, item);
|
this.active.set(id, item);
|
||||||
this._ensureTimer();
|
this._ensureTimer();
|
||||||
|
this.emit('change', this._reason('play', { id }));
|
||||||
return publicItem(item);
|
return publicItem(item);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -126,6 +154,7 @@ class StreamMixer {
|
|||||||
this.active.delete(id);
|
this.active.delete(id);
|
||||||
if (item.proc && !item.proc.killed) item.proc.kill('SIGTERM');
|
if (item.proc && !item.proc.killed) item.proc.kill('SIGTERM');
|
||||||
this._maybeStopTimer();
|
this._maybeStopTimer();
|
||||||
|
this.emit('change', this._reason('stop', { id }));
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +165,7 @@ class StreamMixer {
|
|||||||
}
|
}
|
||||||
this.active.clear();
|
this.active.clear();
|
||||||
this._maybeStopTimer();
|
this._maybeStopTimer();
|
||||||
|
if (count) this.emit('change', this._reason('stopAll', { count }));
|
||||||
return count;
|
return count;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -144,13 +174,19 @@ class StreamMixer {
|
|||||||
sampleRate: SAMPLE_RATE,
|
sampleRate: SAMPLE_RATE,
|
||||||
channels: CHANNELS,
|
channels: CHANNELS,
|
||||||
clients: this.clients.size,
|
clients: this.clients.size,
|
||||||
|
droppedFrames: [...this.clients].reduce((n, c) => n + c.droppedFrames, 0),
|
||||||
active: this.listActive(),
|
active: this.listActive(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_reason(kind, extra = null) {
|
||||||
|
return { kind, at: Date.now(), extra };
|
||||||
|
}
|
||||||
|
|
||||||
_ensureTimer() {
|
_ensureTimer() {
|
||||||
if (this.timer) return;
|
if (this.timer) return;
|
||||||
this.timer = setInterval(() => this._tick(), FRAME_MS);
|
this.nextFrameAt = Date.now() + FRAME_MS;
|
||||||
|
this.timer = setInterval(() => this._tick(), Math.max(2, Math.floor(FRAME_MS / 2)));
|
||||||
this.timer.unref?.();
|
this.timer.unref?.();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,14 +194,29 @@ class StreamMixer {
|
|||||||
if (this.clients.size || this.active.size) return;
|
if (this.clients.size || this.active.size) return;
|
||||||
if (this.timer) clearInterval(this.timer);
|
if (this.timer) clearInterval(this.timer);
|
||||||
this.timer = null;
|
this.timer = null;
|
||||||
|
this.nextFrameAt = 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
_tick() {
|
_tick() {
|
||||||
|
const now = Date.now();
|
||||||
|
// Wall-clock pacing: never emit PCM frames ahead of real time. If the
|
||||||
|
// process was delayed, resume at the live edge instead of trying to catch
|
||||||
|
// up by writing a burst, which would create exactly the queued-delay
|
||||||
|
// behavior the soundboard is trying to avoid.
|
||||||
|
if (this.nextFrameAt && now + 1 < this.nextFrameAt) return;
|
||||||
|
if (!this.nextFrameAt || now - this.nextFrameAt > FRAME_MS * 2) {
|
||||||
|
this.nextFrameAt = now + FRAME_MS;
|
||||||
|
} else {
|
||||||
|
this.nextFrameAt += FRAME_MS;
|
||||||
|
}
|
||||||
|
|
||||||
const output = Buffer.alloc(FRAME_BYTES);
|
const output = Buffer.alloc(FRAME_BYTES);
|
||||||
const mix = new Int32Array(FRAME_BYTES / BYTES_PER_SAMPLE);
|
const mix = new Int32Array(FRAME_BYTES / BYTES_PER_SAMPLE);
|
||||||
const now = Date.now();
|
let endedSomething = false;
|
||||||
|
|
||||||
for (const item of [...this.active.values()]) {
|
for (const item of [...this.active.values()]) {
|
||||||
|
// If a decoder is late its slot stays silent for this tick rather
|
||||||
|
// than holding the whole mix back — the live edge always advances.
|
||||||
const frame = consumeFrame(item, FRAME_BYTES);
|
const frame = consumeFrame(item, FRAME_BYTES);
|
||||||
if (frame) {
|
if (frame) {
|
||||||
for (let i = 0; i < mix.length; i += 1) {
|
for (let i = 0; i < mix.length; i += 1) {
|
||||||
@@ -174,6 +225,7 @@ class StreamMixer {
|
|||||||
}
|
}
|
||||||
if (item.ended && item.queueBytes === 0) {
|
if (item.ended && item.queueBytes === 0) {
|
||||||
this.active.delete(item.id);
|
this.active.delete(item.id);
|
||||||
|
endedSomething = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -181,19 +233,37 @@ class StreamMixer {
|
|||||||
output.writeInt16LE(clamp(mix[i], -32768, 32767), i * 2);
|
output.writeInt16LE(clamp(mix[i], -32768, 32767), i * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let clientChanged = false;
|
||||||
for (const client of [...this.clients]) {
|
for (const client of [...this.clients]) {
|
||||||
if (now < client.mutedUntil) continue;
|
if (now < client.mutedUntil) continue;
|
||||||
if (client.res.destroyed || client.res.writableEnded) {
|
if (client.res.destroyed || client.res.writableEnded) {
|
||||||
this.clients.delete(client);
|
this.clients.delete(client);
|
||||||
|
clientChanged = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
// Backpressure-aware drop. If the player is slow and the writable
|
||||||
|
// buffer is already holding more than MAX_CLIENT_BUFFER_BYTES
|
||||||
|
// (~60ms) of audio, skip this frame instead of stacking onto it.
|
||||||
|
// Without this, the WAV stream silently turns into a multi-second
|
||||||
|
// delay line: button presses still mix correctly but the listener
|
||||||
|
// hears them only after the backlog drains.
|
||||||
|
const buffered = client.res.writableLength | 0;
|
||||||
|
const needsDrain = client.res.writableNeedDrain === true;
|
||||||
|
if (needsDrain || buffered > MAX_CLIENT_BUFFER_BYTES) {
|
||||||
|
client.droppedFrames += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
client.res.write(output);
|
client.res.write(output);
|
||||||
} catch {
|
} catch {
|
||||||
this.clients.delete(client);
|
this.clients.delete(client);
|
||||||
|
clientChanged = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (endedSomething || clientChanged) {
|
||||||
|
this.emit('change', this._reason(endedSomething ? 'effectEnded' : 'clientChanged'));
|
||||||
|
}
|
||||||
this._maybeStopTimer();
|
this._maybeStopTimer();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
88
src/wsHub.js
Normal file
88
src/wsHub.js
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
'use strict';
|
||||||
|
|
||||||
|
const { WebSocketServer } = require('ws');
|
||||||
|
|
||||||
|
const STATUS_PATH = '/ws/status';
|
||||||
|
const AGE_TICK_MS = 1000;
|
||||||
|
|
||||||
|
function attachStatusWebSocket({ server, mixer, entity }) {
|
||||||
|
if (!server) throw new Error('attachStatusWebSocket requires server');
|
||||||
|
if (!mixer) throw new Error('attachStatusWebSocket requires mixer');
|
||||||
|
|
||||||
|
const wss = new WebSocketServer({ noServer: true });
|
||||||
|
let ageTimer = null;
|
||||||
|
|
||||||
|
function payload(reason = 'snapshot') {
|
||||||
|
return JSON.stringify({
|
||||||
|
type: 'streamStatus',
|
||||||
|
reason,
|
||||||
|
entity,
|
||||||
|
stream: mixer.stats(),
|
||||||
|
time: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send(ws, reason = 'snapshot') {
|
||||||
|
if (ws.readyState !== ws.OPEN) return;
|
||||||
|
try { ws.send(payload(reason)); } catch { /* noop */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
function broadcast(reason = 'change') {
|
||||||
|
for (const ws of wss.clients) send(ws, reason);
|
||||||
|
manageAgeTimer();
|
||||||
|
}
|
||||||
|
|
||||||
|
function manageAgeTimer() {
|
||||||
|
const hasClients = wss.clients.size > 0;
|
||||||
|
const hasActive = mixer.stats().active.length > 0;
|
||||||
|
if (hasClients && hasActive && !ageTimer) {
|
||||||
|
ageTimer = setInterval(() => broadcast('age'), AGE_TICK_MS);
|
||||||
|
ageTimer.unref?.();
|
||||||
|
} else if ((!hasClients || !hasActive) && ageTimer) {
|
||||||
|
clearInterval(ageTimer);
|
||||||
|
ageTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
server.on('upgrade', (req, socket, head) => {
|
||||||
|
let pathname = '';
|
||||||
|
try {
|
||||||
|
pathname = new URL(req.url || '/', 'http://localhost').pathname;
|
||||||
|
} catch {
|
||||||
|
socket.destroy();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (pathname !== STATUS_PATH) return;
|
||||||
|
|
||||||
|
wss.handleUpgrade(req, socket, head, (ws) => {
|
||||||
|
wss.emit('connection', ws, req);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
wss.on('connection', (ws) => {
|
||||||
|
send(ws, 'connect');
|
||||||
|
manageAgeTimer();
|
||||||
|
ws.on('message', (raw) => {
|
||||||
|
let msg = null;
|
||||||
|
try { msg = JSON.parse(String(raw)); } catch { /* ignore */ }
|
||||||
|
if (msg && msg.type === 'getStatus') send(ws, 'requested');
|
||||||
|
});
|
||||||
|
ws.on('close', manageAgeTimer);
|
||||||
|
ws.on('error', () => { try { ws.close(); } catch { /* noop */ } });
|
||||||
|
});
|
||||||
|
|
||||||
|
const onMixerChange = (evt) => broadcast((evt && evt.kind) || 'change');
|
||||||
|
mixer.on('change', onMixerChange);
|
||||||
|
|
||||||
|
return {
|
||||||
|
wss,
|
||||||
|
broadcast,
|
||||||
|
close() {
|
||||||
|
mixer.off('change', onMixerChange);
|
||||||
|
if (ageTimer) clearInterval(ageTimer);
|
||||||
|
wss.close();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { attachStatusWebSocket, STATUS_PATH };
|
||||||
Reference in New Issue
Block a user