mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
Merge pull request #18 from Saul5662/feat/user-volume-gains
feat(audio): per-user horn/TTS/forward gains with admin-capped ceilings and a VIP boost flag
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
// audio Levels Gain Math
|
||||||
|
// Purpose: Holds the pure clamping and ceiling rules shared by every gain layer.
|
||||||
|
// Scope: No IO, no state; keeps the volume policy independently reviewable and testable.
|
||||||
|
|
||||||
|
/*
|
||||||
|
The three gain keys are the same on every layer of this feature: the global
|
||||||
|
admin gains, the admin-editable VIP boost caps, and each user's personal
|
||||||
|
preference. Iterating one list keeps those layers from drifting apart.
|
||||||
|
*/
|
||||||
|
const GAIN_KEYS = ['hornGain', 'ttsGain', 'forwardGain'];
|
||||||
|
|
||||||
|
// Absolute gain limits accepted anywhere a multiplier is stored.
|
||||||
|
const MIN_GAIN = 0;
|
||||||
|
const MAX_GAIN = 4;
|
||||||
|
|
||||||
|
function clampGain(value, fallback = 1) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return fallback;
|
||||||
|
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, num));
|
||||||
|
}
|
||||||
|
|
||||||
|
function clampFraction(value, fallback = 1) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return fallback;
|
||||||
|
return Math.max(0, Math.min(1, num));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeUserGains(raw = {}) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
out[key] = clampFraction(raw?.[key], 1);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeGainSet(raw = {}, fallback = {}) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
A user without the boost flag can never exceed the global admin gain. The flag
|
||||||
|
raises the ceiling to the admin-managed hard cap, and Math.max keeps the flag
|
||||||
|
from ever being a downgrade: if an admin runs the global gain higher than the
|
||||||
|
boost cap, a boosted user keeps the global ceiling instead of losing volume
|
||||||
|
for holding a permission.
|
||||||
|
*/
|
||||||
|
function resolveCeilings({ adminLimits = {}, boostCaps = {}, hasBoost = false } = {}) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
const adminCeiling = clampGain(adminLimits?.[key], 0);
|
||||||
|
out[key] = hasBoost ? Math.max(adminCeiling, clampGain(boostCaps?.[key], 0)) : adminCeiling;
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Personal preferences are fractions of whichever ceiling applies to the user.
|
||||||
|
function applyCeilings(fractions = {}, ceilings = {}) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
GAIN_KEYS,
|
||||||
|
MIN_GAIN,
|
||||||
|
MAX_GAIN,
|
||||||
|
clampGain,
|
||||||
|
clampFraction,
|
||||||
|
normalizeUserGains,
|
||||||
|
normalizeGainSet,
|
||||||
|
resolveCeilings,
|
||||||
|
applyCeilings,
|
||||||
|
};
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
// audio Levels Gain Math Tests
|
||||||
|
// Purpose: Pins the ceiling rules that keep user volume inside admin limits.
|
||||||
|
// Scope: Pure math only; no store, socket, or rover involvement.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const {
|
||||||
|
clampFraction,
|
||||||
|
clampGain,
|
||||||
|
normalizeUserGains,
|
||||||
|
normalizeGainSet,
|
||||||
|
resolveCeilings,
|
||||||
|
applyCeilings,
|
||||||
|
} = require('./gainMath');
|
||||||
|
|
||||||
|
const ADMIN_LIMITS = { hornGain: 0.3, ttsGain: 0.2, forwardGain: 0.1 };
|
||||||
|
const BOOST_CAPS = { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 };
|
||||||
|
|
||||||
|
test('an unboosted user is capped by the global admin gains', () => {
|
||||||
|
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: false });
|
||||||
|
assert.deepEqual(ceilings, ADMIN_LIMITS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the boost flag raises the ceiling to the hard caps', () => {
|
||||||
|
const ceilings = resolveCeilings({ adminLimits: ADMIN_LIMITS, boostCaps: BOOST_CAPS, hasBoost: true });
|
||||||
|
assert.deepEqual(ceilings, BOOST_CAPS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('the boost flag never lowers a ceiling when admin gains exceed the caps', () => {
|
||||||
|
const loud = { hornGain: 2, ttsGain: 1.5, forwardGain: 3 };
|
||||||
|
const ceilings = resolveCeilings({ adminLimits: loud, boostCaps: BOOST_CAPS, hasBoost: true });
|
||||||
|
assert.deepEqual(ceilings, loud);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a full personal slider resolves to exactly the ceiling', () => {
|
||||||
|
const effective = applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ADMIN_LIMITS);
|
||||||
|
assert.deepEqual(effective, ADMIN_LIMITS);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a personal slider scales the ceiling rather than replacing it', () => {
|
||||||
|
const effective = applyCeilings({ hornGain: 0.5, ttsGain: 0.5, forwardGain: 0.5 }, BOOST_CAPS);
|
||||||
|
assert.deepEqual(effective, { hornGain: 0.25, ttsGain: 0.4, forwardGain: 0.2 });
|
||||||
|
});
|
||||||
|
|
||||||
|
test('an out-of-range personal value cannot escape the ceiling', () => {
|
||||||
|
const effective = applyCeilings({ hornGain: 12, ttsGain: -4, forwardGain: 'loud' }, ADMIN_LIMITS);
|
||||||
|
assert.equal(effective.hornGain, ADMIN_LIMITS.hornGain);
|
||||||
|
assert.equal(effective.ttsGain, 0);
|
||||||
|
// A non-numeric value falls back to the full slider, still bounded by the ceiling.
|
||||||
|
assert.equal(effective.forwardGain, ADMIN_LIMITS.forwardGain);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a zero admin gain silences even a boosted user at full slider', () => {
|
||||||
|
const ceilings = resolveCeilings({
|
||||||
|
adminLimits: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
|
||||||
|
boostCaps: { hornGain: 0, ttsGain: 0, forwardGain: 0 },
|
||||||
|
hasBoost: true,
|
||||||
|
});
|
||||||
|
assert.deepEqual(applyCeilings({ hornGain: 1, ttsGain: 1, forwardGain: 1 }, ceilings), {
|
||||||
|
hornGain: 0,
|
||||||
|
ttsGain: 0,
|
||||||
|
forwardGain: 0,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('personal values normalize into the 0..1 range with a full-volume default', () => {
|
||||||
|
assert.deepEqual(normalizeUserGains({ hornGain: 0.25, ttsGain: 9 }), {
|
||||||
|
hornGain: 0.25,
|
||||||
|
ttsGain: 1,
|
||||||
|
forwardGain: 1,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('gain sets normalize into the 0..4 range and fall back per key', () => {
|
||||||
|
assert.deepEqual(normalizeGainSet({ hornGain: 9, ttsGain: 'x' }, BOOST_CAPS), {
|
||||||
|
hornGain: 4,
|
||||||
|
ttsGain: BOOST_CAPS.ttsGain,
|
||||||
|
forwardGain: BOOST_CAPS.forwardGain,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('clamps reject non-finite input by returning the supplied fallback', () => {
|
||||||
|
assert.equal(clampGain(Number.NaN, 0.7), 0.7);
|
||||||
|
assert.equal(clampGain(Infinity, 0.7), 0.7);
|
||||||
|
assert.equal(clampFraction(undefined, 0.4), 0.4);
|
||||||
|
});
|
||||||
@@ -9,24 +9,53 @@ const { loadConfig } = require('../../helpers/configLoader');
|
|||||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||||
const { isAdmin } = require('../roleService');
|
const { isAdmin } = require('../roleService');
|
||||||
const roverManager = require('../roverManager');
|
const roverManager = require('../roverManager');
|
||||||
|
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
||||||
const { issueCommand } = require('../commandService');
|
const { issueCommand } = require('../commandService');
|
||||||
|
const {
|
||||||
|
GAIN_KEYS,
|
||||||
|
clampGain,
|
||||||
|
clampFraction,
|
||||||
|
normalizeUserGains,
|
||||||
|
normalizeGainSet,
|
||||||
|
resolveCeilings,
|
||||||
|
applyCeilings,
|
||||||
|
} = require('./gainMath');
|
||||||
|
|
||||||
const audioLevelsEvents = new EventEmitter();
|
const audioLevelsEvents = new EventEmitter();
|
||||||
const DATA_DIR = resolveDataDir();
|
const DATA_DIR = resolveDataDir();
|
||||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||||
const config = loadConfig();
|
const config = loadConfig();
|
||||||
const configuredDefaults = config.audioLevels || {};
|
const configuredDefaults = config.audioLevels || {};
|
||||||
|
const configuredUserCaps = configuredDefaults.userGainCaps || {};
|
||||||
|
|
||||||
|
/*
|
||||||
|
Per-user preferences live in identity feature state so they follow the user
|
||||||
|
across browsers and cannot be raised by editing a client-side cookie. They are
|
||||||
|
stored as a 0..1 fraction of whatever ceiling currently applies rather than an
|
||||||
|
absolute gain, so lowering the global admin gain immediately quiets everyone
|
||||||
|
without having to rewrite every stored preference.
|
||||||
|
*/
|
||||||
|
const USER_GAINS_NAMESPACE = 'audioGains';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Absolute ceilings for users holding the audioGainBoost flag. These are the
|
||||||
|
hard caps the flag cannot exceed; admins can retune them from the driver page.
|
||||||
|
*/
|
||||||
|
const USER_GAIN_CAP_DEFAULTS = {
|
||||||
|
hornGain: 0.5,
|
||||||
|
ttsGain: 0.8,
|
||||||
|
forwardGain: 0.4,
|
||||||
|
};
|
||||||
|
|
||||||
const DEFAULTS = {
|
const DEFAULTS = {
|
||||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||||
|
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
|
||||||
};
|
};
|
||||||
|
|
||||||
function clampGain(value, fallback = 1) {
|
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
||||||
const num = Number(value);
|
return normalizeGainSet(raw, fallback);
|
||||||
if (!Number.isFinite(num)) return fallback;
|
|
||||||
return Math.max(0, Math.min(4, num));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeStore(raw = {}) {
|
function normalizeStore(raw = {}) {
|
||||||
@@ -34,8 +63,11 @@ function normalizeStore(raw = {}) {
|
|||||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||||
|
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
|
||||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : null,
|
||||||
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
updatedBy: typeof raw.updatedBy === 'string' ? raw.updatedBy : null,
|
||||||
|
capsUpdatedAt: Number.isFinite(raw.capsUpdatedAt) ? raw.capsUpdatedAt : null,
|
||||||
|
capsUpdatedBy: typeof raw.capsUpdatedBy === 'string' ? raw.capsUpdatedBy : null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,18 +103,94 @@ function getAudioLevels() {
|
|||||||
hornGain: current.hornGain,
|
hornGain: current.hornGain,
|
||||||
ttsGain: current.ttsGain,
|
ttsGain: current.ttsGain,
|
||||||
forwardGain: current.forwardGain,
|
forwardGain: current.forwardGain,
|
||||||
|
userGainCaps: { ...current.userGainCaps },
|
||||||
updatedAt: current.updatedAt,
|
updatedAt: current.updatedAt,
|
||||||
updatedBy: current.updatedBy,
|
updatedBy: current.updatedBy,
|
||||||
|
capsUpdatedAt: current.capsUpdatedAt,
|
||||||
|
capsUpdatedBy: current.capsUpdatedBy,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function emitChange(reason = 'update') {
|
function getUserGainCaps() {
|
||||||
|
return { ...loadState().userGainCaps };
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitChange(reason = 'update', extra = {}) {
|
||||||
audioLevelsEvents.emit('change', {
|
audioLevelsEvents.emit('change', {
|
||||||
reason,
|
reason,
|
||||||
levels: getAudioLevels(),
|
levels: getAudioLevels(),
|
||||||
|
...extra,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getAdminLimits() {
|
||||||
|
const current = loadState();
|
||||||
|
return {
|
||||||
|
hornGain: current.hornGain,
|
||||||
|
ttsGain: current.ttsGain,
|
||||||
|
forwardGain: current.forwardGain,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGainCeilings(hasBoost) {
|
||||||
|
const current = loadState();
|
||||||
|
return resolveCeilings({
|
||||||
|
adminLimits: getAdminLimits(),
|
||||||
|
boostCaps: current.userGainCaps,
|
||||||
|
hasBoost,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function getGainCeilingsForSocket(socket) {
|
||||||
|
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserGains(userId) {
|
||||||
|
if (!userId) return normalizeUserGains({});
|
||||||
|
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getUserGainsForSocket(socket) {
|
||||||
|
return getUserGains(getUserIdForSocket(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
function getEffectiveLevelsForSocket(socket) {
|
||||||
|
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The rover applies gain as three ALSA master controls, so only one set of gains
|
||||||
|
can be live per rover at a time. That is not a limitation in practice: horn,
|
||||||
|
TTS, and mic forwarding are all restricted to the socket currently holding
|
||||||
|
audio control, so pushing that socket's resolved gains gives genuinely
|
||||||
|
per-user volume. When nobody owns audio the global admin gains apply.
|
||||||
|
*/
|
||||||
|
function resolveAudioOwnerSocket(roverId) {
|
||||||
|
const record = roverManager.rovers.get(roverId);
|
||||||
|
if (!record) return null;
|
||||||
|
const driverIds = Array.from(record.drivers || []);
|
||||||
|
if (!driverIds.length) return null;
|
||||||
|
|
||||||
|
// Required lazily: turnService reaches back into roverManager during startup.
|
||||||
|
let activeSocketId = null;
|
||||||
|
try {
|
||||||
|
activeSocketId = require('../turnService').getActiveDrivers()[roverId] || null;
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to resolve active driver for audio levels', roverId, err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
const chosenId = activeSocketId && driverIds.includes(activeSocketId)
|
||||||
|
? activeSocketId
|
||||||
|
: (driverIds.length === 1 ? driverIds[0] : null);
|
||||||
|
if (!chosenId) return null;
|
||||||
|
return io.sockets.sockets.get(chosenId) || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveLevelsForRover(roverId) {
|
||||||
|
const owner = resolveAudioOwnerSocket(roverId);
|
||||||
|
return owner ? getEffectiveLevelsForSocket(owner) : getAdminLimits();
|
||||||
|
}
|
||||||
|
|
||||||
function pushLevelsToRover(roverId) {
|
function pushLevelsToRover(roverId) {
|
||||||
if (!roverId) return;
|
if (!roverId) return;
|
||||||
const record = roverManager.rovers.get(roverId);
|
const record = roverManager.rovers.get(roverId);
|
||||||
@@ -90,7 +198,7 @@ function pushLevelsToRover(roverId) {
|
|||||||
try {
|
try {
|
||||||
issueCommand(roverId, {
|
issueCommand(roverId, {
|
||||||
type: 'audioLevels',
|
type: 'audioLevels',
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: resolveLevelsForRover(roverId),
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
logger.warn('Failed to push audio levels to rover', roverId, err.message);
|
||||||
@@ -105,6 +213,11 @@ function pushLevelsToAllRovers() {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function pushLevelsForSocket(socket) {
|
||||||
|
if (!socket) return;
|
||||||
|
roverManager.getRoversForSocket(socket.id).forEach((roverId) => pushLevelsToRover(roverId));
|
||||||
|
}
|
||||||
|
|
||||||
function setAudioLevels(input = {}, actor = null) {
|
function setAudioLevels(input = {}, actor = null) {
|
||||||
const current = loadState();
|
const current = loadState();
|
||||||
const next = {
|
const next = {
|
||||||
@@ -121,12 +234,83 @@ function setAudioLevels(input = {}, actor = null) {
|
|||||||
return getAudioLevels();
|
return getAudioLevels();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setUserGainCaps(input = {}, actor = null) {
|
||||||
|
const current = loadState();
|
||||||
|
const next = {
|
||||||
|
...current,
|
||||||
|
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
|
||||||
|
capsUpdatedAt: Date.now(),
|
||||||
|
capsUpdatedBy: actor,
|
||||||
|
};
|
||||||
|
persistState(next);
|
||||||
|
/*
|
||||||
|
Lowering a cap has to take effect immediately for anyone already driving,
|
||||||
|
otherwise a boosted user keeps the louder gain until their next turn.
|
||||||
|
*/
|
||||||
|
pushLevelsToAllRovers();
|
||||||
|
emitChange('user_caps_set');
|
||||||
|
return getUserGainCaps();
|
||||||
|
}
|
||||||
|
|
||||||
|
function setUserGains(socket, input = {}) {
|
||||||
|
const userId = getUserIdForSocket(socket);
|
||||||
|
if (!userId) throw new Error('Identity required');
|
||||||
|
const current = getUserGains(userId);
|
||||||
|
const next = { ...current };
|
||||||
|
GAIN_KEYS.forEach((key) => {
|
||||||
|
if (input?.[key] === undefined) return;
|
||||||
|
next[key] = clampFraction(input[key], current[key]);
|
||||||
|
});
|
||||||
|
setFeatureState(userId, USER_GAINS_NAMESPACE, next);
|
||||||
|
pushLevelsForSocket(socket);
|
||||||
|
emitChange('user_gains_set', { scope: 'user', userId });
|
||||||
|
return getAudioGainStateForSocket(socket);
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The client needs all three layers to render an honest slider: its own stored
|
||||||
|
fraction, the ceiling that fraction is measured against, and the resolved gain
|
||||||
|
so the UI can show what the rover will actually play.
|
||||||
|
*/
|
||||||
|
function getAudioGainStateForSocket(socket) {
|
||||||
|
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
||||||
|
const values = getUserGainsForSocket(socket);
|
||||||
|
const ceilings = getGainCeilings(hasBoost);
|
||||||
|
return {
|
||||||
|
values,
|
||||||
|
ceilings,
|
||||||
|
effective: applyCeilings(values, ceilings),
|
||||||
|
boostGranted: hasBoost,
|
||||||
|
adminLimits: getAdminLimits(),
|
||||||
|
boostCaps: getUserGainCaps(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
roverManager.managerEvents.on('rover', ({ roverId, action } = {}) => {
|
||||||
if (action === 'upsert' && roverId) {
|
if (action === 'upsert' && roverId) {
|
||||||
pushLevelsToRover(roverId);
|
pushLevelsToRover(roverId);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/*
|
||||||
|
Whoever owns a rover's audio determines which gains are live, so the rover has
|
||||||
|
to be re-pushed whenever that ownership moves: joining or leaving a rover, and
|
||||||
|
every turn rotation.
|
||||||
|
*/
|
||||||
|
roverManager.managerEvents.on('driver', ({ roverId } = {}) => {
|
||||||
|
if (roverId) pushLevelsToRover(roverId);
|
||||||
|
});
|
||||||
|
|
||||||
|
setImmediate(() => {
|
||||||
|
try {
|
||||||
|
require('../turnService').turnEvents.on('queue', ({ roverId } = {}) => {
|
||||||
|
if (roverId) pushLevelsToRover(roverId);
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
logger.warn('Failed to subscribe to turn changes for audio levels', err.message);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
io.on('connection', (socket) => {
|
io.on('connection', (socket) => {
|
||||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||||
cb({ success: true, levels: getAudioLevels() });
|
cb({ success: true, levels: getAudioLevels() });
|
||||||
@@ -144,13 +328,51 @@ io.on('connection', (socket) => {
|
|||||||
cb({ error: err.message });
|
cb({ error: err.message });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
if (!isAdmin(socket)) {
|
||||||
|
throw new Error('Not authorized');
|
||||||
|
}
|
||||||
|
const actor = socket?.data?.user?.username || null;
|
||||||
|
const userGainCaps = setUserGainCaps(payload || {}, actor);
|
||||||
|
cb({ success: true, userGainCaps });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
|
||||||
|
try {
|
||||||
|
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
|
||||||
|
} catch (err) {
|
||||||
|
cb({ error: err.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
loadState();
|
loadState();
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
|
GAIN_KEYS,
|
||||||
|
USER_GAIN_CAP_DEFAULTS,
|
||||||
getAudioLevels,
|
getAudioLevels,
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
|
getUserGainCaps,
|
||||||
|
setUserGainCaps,
|
||||||
|
getUserGains,
|
||||||
|
setUserGains,
|
||||||
|
getGainCeilingsForSocket,
|
||||||
|
getEffectiveLevelsForSocket,
|
||||||
|
getAudioGainStateForSocket,
|
||||||
pushLevelsToRover,
|
pushLevelsToRover,
|
||||||
audioLevelsEvents,
|
audioLevelsEvents,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -22,6 +22,9 @@ const {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
const { publishEvent } = require('../eventBus');
|
const { publishEvent } = require('../eventBus');
|
||||||
const assignmentService = require('../assignmentService');
|
const assignmentService = require('../assignmentService');
|
||||||
@@ -184,6 +187,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: null,
|
sendToChannel: null,
|
||||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ const {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
} = require('../verificationService');
|
} = require('../verificationService');
|
||||||
const {
|
const {
|
||||||
attachDmMessage: attachPrivateAccessDmMessage,
|
attachDmMessage: attachPrivateAccessDmMessage,
|
||||||
@@ -250,6 +253,9 @@ const commandDependencies = {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
sanitizeMentions,
|
sanitizeMentions,
|
||||||
sendToChannel: channelIO.sendToChannel,
|
sendToChannel: channelIO.sendToChannel,
|
||||||
isAdminUser,
|
isAdminUser,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
|
|||||||
const DB_PATH = resolveDataPath('identity.sqlite');
|
const DB_PATH = resolveDataPath('identity.sqlite');
|
||||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||||
const STORE_VERSION = 2;
|
const STORE_VERSION = 3;
|
||||||
const identityEvents = new EventEmitter();
|
const identityEvents = new EventEmitter();
|
||||||
|
|
||||||
let db = null;
|
let db = null;
|
||||||
@@ -169,7 +169,10 @@ function ensureSchema(conn) {
|
|||||||
deterrence_by text,
|
deterrence_by text,
|
||||||
muted_enabled integer not null default 0,
|
muted_enabled integer not null default 0,
|
||||||
muted_at integer,
|
muted_at integer,
|
||||||
muted_by text
|
muted_by text,
|
||||||
|
audio_gain_boost_enabled integer not null default 0,
|
||||||
|
audio_gain_boost_at integer,
|
||||||
|
audio_gain_boost_by text
|
||||||
);
|
);
|
||||||
|
|
||||||
create table if not exists verification_requests (
|
create table if not exists verification_requests (
|
||||||
@@ -220,7 +223,8 @@ function ensureSchema(conn) {
|
|||||||
/*
|
/*
|
||||||
SQLite's `create table if not exists` leaves an existing table untouched.
|
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||||
Add the mute columns explicitly for installations created before store
|
Add the mute columns explicitly for installations created before store
|
||||||
version 2, while the column-name check keeps every later startup idempotent.
|
version 2, and the audio gain boost columns for those created before store
|
||||||
|
version 3. The column-name check keeps every later startup idempotent.
|
||||||
*/
|
*/
|
||||||
const statusColumns = new Set(
|
const statusColumns = new Set(
|
||||||
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||||
@@ -234,6 +238,15 @@ function ensureSchema(conn) {
|
|||||||
if (!statusColumns.has('muted_by')) {
|
if (!statusColumns.has('muted_by')) {
|
||||||
conn.exec('alter table user_status add column muted_by text');
|
conn.exec('alter table user_status add column muted_by text');
|
||||||
}
|
}
|
||||||
|
if (!statusColumns.has('audio_gain_boost_enabled')) {
|
||||||
|
conn.exec('alter table user_status add column audio_gain_boost_enabled integer not null default 0');
|
||||||
|
}
|
||||||
|
if (!statusColumns.has('audio_gain_boost_at')) {
|
||||||
|
conn.exec('alter table user_status add column audio_gain_boost_at integer');
|
||||||
|
}
|
||||||
|
if (!statusColumns.has('audio_gain_boost_by')) {
|
||||||
|
conn.exec('alter table user_status add column audio_gain_boost_by text');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function createUser(conn = getDb(), ts = nowMs()) {
|
function createUser(conn = getDb(), ts = nowMs()) {
|
||||||
@@ -418,6 +431,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
|||||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||||
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||||
|
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
|
||||||
}
|
}
|
||||||
|
|
||||||
function identifySocket(socket, payload = {}) {
|
function identifySocket(socket, payload = {}) {
|
||||||
@@ -509,6 +523,11 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
|||||||
mutedAt: status.muted_at || null,
|
mutedAt: status.muted_at || null,
|
||||||
mutedBy: status.muted_by || null,
|
mutedBy: status.muted_by || null,
|
||||||
},
|
},
|
||||||
|
audioGainBoost: {
|
||||||
|
enabled: Boolean(status.audio_gain_boost_enabled),
|
||||||
|
at: status.audio_gain_boost_at || null,
|
||||||
|
by: status.audio_gain_boost_by || null,
|
||||||
|
},
|
||||||
features,
|
features,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -726,21 +745,48 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
|||||||
return getUserById(id);
|
return getUserById(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The audio gain boost flag lets a trusted VIP raise their personal horn/TTS/mic
|
||||||
|
gain ceiling past the global admin gain settings. It stays a status column
|
||||||
|
rather than feature state so it can be filtered in SQL alongside the other
|
||||||
|
moderation flags and copied onto the socket at identify time.
|
||||||
|
*/
|
||||||
|
function setAudioGainBoost(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||||
|
const id = String(userId || '').trim();
|
||||||
|
if (!id) throw new Error('userId required');
|
||||||
|
ensureUserStatus(getDb(), id);
|
||||||
|
getDb().prepare(`
|
||||||
|
update user_status
|
||||||
|
set audio_gain_boost_enabled = ?, audio_gain_boost_at = ?, audio_gain_boost_by = ?
|
||||||
|
where user_id = ?
|
||||||
|
`).run(enabled ? 1 : 0, enabled ? at : null, enabled ? actor : null, id);
|
||||||
|
identityEvents.emit('change', {
|
||||||
|
reason: enabled ? 'audio_gain_boost_granted' : 'audio_gain_boost_revoked',
|
||||||
|
userId: id,
|
||||||
|
});
|
||||||
|
return getUserById(id);
|
||||||
|
}
|
||||||
|
|
||||||
function isVerified(socket) {
|
function isVerified(socket) {
|
||||||
return Boolean(socket?.data?.isVerified);
|
return Boolean(socket?.data?.isVerified);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasAudioGainBoost(socket) {
|
||||||
|
return Boolean(socket?.data?.hasAudioGainBoost);
|
||||||
|
}
|
||||||
|
|
||||||
function isDeterred(socket) {
|
function isDeterred(socket) {
|
||||||
return Boolean(socket?.data?.isDeterred);
|
return Boolean(socket?.data?.isDeterred);
|
||||||
}
|
}
|
||||||
|
|
||||||
function listUsers({ verified = null, deterred = null, muted = null } = {}) {
|
function listUsers({ verified = null, deterred = null, muted = null, audioGainBoost = null } = {}) {
|
||||||
const conn = getDb();
|
const conn = getDb();
|
||||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||||
const where = [];
|
const where = [];
|
||||||
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
if (verified !== null) where.push(`user_status.verified_enabled = ${verified ? 1 : 0}`);
|
||||||
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
if (deterred !== null) where.push(`user_status.deterrence_enabled = ${deterred ? 1 : 0}`);
|
||||||
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
if (muted !== null) where.push(`user_status.muted_enabled = ${muted ? 1 : 0}`);
|
||||||
|
if (audioGainBoost !== null) where.push(`user_status.audio_gain_boost_enabled = ${audioGainBoost ? 1 : 0}`);
|
||||||
if (where.length) sql += ` where ${where.join(' and ')}`;
|
if (where.length) sql += ` where ${where.join(' and ')}`;
|
||||||
sql += ' order by users.updated_at desc';
|
sql += ' order by users.updated_at desc';
|
||||||
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
return conn.prepare(sql).all().map((row) => getUserById(row.id, { conn, includeFeatures: false }));
|
||||||
@@ -762,6 +808,9 @@ function userToLegacyIdentityEntry(user) {
|
|||||||
muted: Boolean(user.deterrence?.muted),
|
muted: Boolean(user.deterrence?.muted),
|
||||||
mutedAt: user.deterrence?.mutedAt || null,
|
mutedAt: user.deterrence?.mutedAt || null,
|
||||||
mutedBy: user.deterrence?.mutedBy || null,
|
mutedBy: user.deterrence?.mutedBy || null,
|
||||||
|
audioGainBoost: Boolean(user.audioGainBoost?.enabled),
|
||||||
|
audioGainBoostAt: user.audioGainBoost?.at || null,
|
||||||
|
audioGainBoostBy: user.audioGainBoost?.by || null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -777,6 +826,10 @@ function listMutedUsers() {
|
|||||||
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
return listUsers({ muted: true }).map(userToLegacyIdentityEntry);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function listAudioGainBoostUsers() {
|
||||||
|
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
|
||||||
|
}
|
||||||
|
|
||||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||||
const value = String(selector || '').trim();
|
const value = String(selector || '').trim();
|
||||||
if (!value) return { error: 'selector_required' };
|
if (!value) return { error: 'selector_required' };
|
||||||
@@ -1031,11 +1084,14 @@ module.exports = {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
|
setAudioGainBoost,
|
||||||
isVerified,
|
isVerified,
|
||||||
isDeterred,
|
isDeterred,
|
||||||
|
hasAudioGainBoost,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
listMutedUsers,
|
listMutedUsers,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
createJsonStore,
|
createJsonStore,
|
||||||
|
|||||||
@@ -0,0 +1,87 @@
|
|||||||
|
// Operator Gain Command
|
||||||
|
// Purpose: Handles the audio gain boost permission for VIPs.
|
||||||
|
// Scope: Supports list, grant, and revoke subcommands; resolution stays VIP-only.
|
||||||
|
const { mask, resolveIdentitySelector } = require('./resolvers');
|
||||||
|
const { getCommandConfig } = require('../../operatorCommandService/config');
|
||||||
|
|
||||||
|
function createGainCommand({
|
||||||
|
listVerifiedUsers,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
|
sanitizeMentions,
|
||||||
|
config,
|
||||||
|
}) {
|
||||||
|
// Usage text comes from the same core prefix that both transports parse.
|
||||||
|
const { prefix: commandPrefix } = getCommandConfig(config);
|
||||||
|
const plain = { parse: [], repliedUser: false };
|
||||||
|
|
||||||
|
function usage(subcommand) {
|
||||||
|
return `Usage: \`${commandPrefix} gain ${subcommand} <nickname|userId|cookieUserId>\``;
|
||||||
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
The boost is a VIP-only permission, so candidate matching runs against the
|
||||||
|
verified list rather than every known identity. A nickname that only belongs
|
||||||
|
to an unverified visitor therefore reports "not found" instead of resolving
|
||||||
|
to someone who cannot hold the flag anyway.
|
||||||
|
*/
|
||||||
|
async function applyBoost(message, tokens, enabled) {
|
||||||
|
const selector = tokens.join(' ').trim();
|
||||||
|
if (!selector) {
|
||||||
|
return message.reply({ content: usage(enabled ? 'grant' : 'revoke'), allowedMentions: plain });
|
||||||
|
}
|
||||||
|
const candidates = enabled ? listVerifiedUsers() : listAudioGainBoostUsers();
|
||||||
|
const resolved = resolveIdentitySelector(selector, candidates, { includeId: false });
|
||||||
|
if (resolved.error) {
|
||||||
|
return message.reply({ content: sanitizeMentions(resolved.error), allowedMentions: plain });
|
||||||
|
}
|
||||||
|
const target = resolved.record.userId || resolved.record.id || resolved.record.cookieUserId;
|
||||||
|
try {
|
||||||
|
const actor = message.actor?.id || null;
|
||||||
|
const user = enabled ? grantAudioGainBoost(target, actor) : revokeAudioGainBoost(target, actor);
|
||||||
|
const verb = enabled ? 'Granted' : 'Revoked';
|
||||||
|
return message.reply({
|
||||||
|
content: sanitizeMentions(`${verb} audio gain boost for ${user.nickname || 'unknown'} (${mask(user.cookieUserId)}).`),
|
||||||
|
allowedMentions: plain,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
return message.reply({
|
||||||
|
content: sanitizeMentions(`Failed to update audio gain boost: ${err.message}`),
|
||||||
|
allowedMentions: plain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return async function handleGainCommand(message, tokens) {
|
||||||
|
if (!message.actor?.isAdmin) {
|
||||||
|
await message.reply({ content: 'Only admins can manage audio gain boosts.', allowedMentions: plain });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const action = (tokens.shift() || 'list').toLowerCase();
|
||||||
|
|
||||||
|
if (action === 'list') {
|
||||||
|
const users = listAudioGainBoostUsers();
|
||||||
|
if (!users.length) {
|
||||||
|
return message.reply({ content: 'No users hold an audio gain boost.', allowedMentions: plain });
|
||||||
|
}
|
||||||
|
const lines = users.map((entry, idx) => (
|
||||||
|
`${idx + 1}. ${entry.nickname || 'unknown'} | ${entry.userId || entry.id} | ${mask(entry.cookieUserId)}`
|
||||||
|
));
|
||||||
|
return message.reply({
|
||||||
|
content: sanitizeMentions(['Audio gain boost holders:', ...lines].join('\n').slice(0, 1900)),
|
||||||
|
allowedMentions: plain,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (action === 'grant') return applyBoost(message, tokens, true);
|
||||||
|
if (action === 'revoke') return applyBoost(message, tokens, false);
|
||||||
|
|
||||||
|
return message.reply({
|
||||||
|
content: `Unknown gain command. Use \`${commandPrefix} gain list\`, \`${commandPrefix} gain grant <vip>\`, or \`${commandPrefix} gain revoke <vip>\`.`,
|
||||||
|
allowedMentions: plain,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { createGainCommand };
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// Operator Gain Command Tests
|
||||||
|
// Purpose: Verifies the audio gain boost command stays admin-only and VIP-only.
|
||||||
|
// Scope: Exercises command target resolution with in-memory identity doubles.
|
||||||
|
const test = require('node:test');
|
||||||
|
const assert = require('node:assert/strict');
|
||||||
|
const { createGainCommand } = require('./gain');
|
||||||
|
|
||||||
|
const VIPS = [
|
||||||
|
{ userId: 'usr-vip', nickname: 'Croissant', cookieUserId: 'cookie-croissant' },
|
||||||
|
{ userId: 'usr-other', nickname: 'Baguette', cookieUserId: 'cookie-baguette' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function createHarness({ verified = VIPS, boosted = [], isAdmin = true } = {}) {
|
||||||
|
const calls = [];
|
||||||
|
const replies = [];
|
||||||
|
const handler = createGainCommand({
|
||||||
|
listVerifiedUsers: () => verified,
|
||||||
|
listAudioGainBoostUsers: () => boosted,
|
||||||
|
grantAudioGainBoost: (selector, actor) => {
|
||||||
|
calls.push({ action: 'grant', selector, actor });
|
||||||
|
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
|
||||||
|
},
|
||||||
|
revokeAudioGainBoost: (selector, actor) => {
|
||||||
|
calls.push({ action: 'revoke', selector, actor });
|
||||||
|
return { nickname: 'Croissant', cookieUserId: 'cookie-croissant' };
|
||||||
|
},
|
||||||
|
sanitizeMentions: (value) => value,
|
||||||
|
config: { commands: { prefix: 'rs' } },
|
||||||
|
});
|
||||||
|
const message = {
|
||||||
|
actor: { id: 'admin', isAdmin },
|
||||||
|
reply: async (payload) => {
|
||||||
|
replies.push(payload);
|
||||||
|
return payload;
|
||||||
|
},
|
||||||
|
};
|
||||||
|
return { handler, message, calls, replies };
|
||||||
|
}
|
||||||
|
|
||||||
|
test('non-admins cannot manage the boost', async () => {
|
||||||
|
const { handler, message, calls, replies } = createHarness({ isAdmin: false });
|
||||||
|
|
||||||
|
await handler(message, ['grant', 'Croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.match(replies[0].content, /Only admins/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grant resolves a VIP nickname to its stable user id', async () => {
|
||||||
|
const { handler, message, calls } = createHarness();
|
||||||
|
|
||||||
|
await handler(message, ['grant', 'croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [{ action: 'grant', selector: 'usr-vip', actor: 'admin' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grant refuses a nickname that belongs to no VIP', async () => {
|
||||||
|
const { handler, message, calls, replies } = createHarness({ verified: [] });
|
||||||
|
|
||||||
|
await handler(message, ['grant', 'Stranger']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.match(replies[0].content, /not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke only matches users who currently hold the boost', async () => {
|
||||||
|
const { handler, message, calls, replies } = createHarness({ boosted: [] });
|
||||||
|
|
||||||
|
await handler(message, ['revoke', 'Croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.match(replies[0].content, /not found/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('revoke resolves against the boosted list', async () => {
|
||||||
|
const { handler, message, calls } = createHarness({ boosted: [VIPS[0]] });
|
||||||
|
|
||||||
|
await handler(message, ['revoke', 'Croissant']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, [{ action: 'revoke', selector: 'usr-vip', actor: 'admin' }]);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('grant without a target prints usage instead of acting', async () => {
|
||||||
|
const { handler, message, calls, replies } = createHarness();
|
||||||
|
|
||||||
|
await handler(message, ['grant']);
|
||||||
|
|
||||||
|
assert.deepEqual(calls, []);
|
||||||
|
assert.match(replies[0].content, /rs gain grant/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list defaults when no subcommand is given', async () => {
|
||||||
|
const { handler, message, replies } = createHarness({ boosted: [VIPS[0]] });
|
||||||
|
|
||||||
|
await handler(message, []);
|
||||||
|
|
||||||
|
assert.match(replies[0].content, /Croissant/);
|
||||||
|
assert.match(replies[0].content, /usr-vip/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('list reports an empty holder set', async () => {
|
||||||
|
const { handler, message, replies } = createHarness({ boosted: [] });
|
||||||
|
|
||||||
|
await handler(message, ['list']);
|
||||||
|
|
||||||
|
assert.match(replies[0].content, /No users hold/);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('a service rejection is surfaced instead of thrown', async () => {
|
||||||
|
const { handler, message, replies } = createHarness();
|
||||||
|
const failing = createGainCommand({
|
||||||
|
listVerifiedUsers: () => VIPS,
|
||||||
|
listAudioGainBoostUsers: () => [],
|
||||||
|
grantAudioGainBoost: () => {
|
||||||
|
throw new Error('Only verified VIPs can be granted an audio gain boost.');
|
||||||
|
},
|
||||||
|
revokeAudioGainBoost: () => null,
|
||||||
|
sanitizeMentions: (value) => value,
|
||||||
|
config: { commands: { prefix: 'rs' } },
|
||||||
|
});
|
||||||
|
|
||||||
|
await failing(message, ['grant', 'Croissant']);
|
||||||
|
|
||||||
|
assert.match(replies[0].content, /Only verified VIPs/);
|
||||||
|
});
|
||||||
@@ -146,7 +146,7 @@ function resolveIdentitySelector(selector, records = [], options = {}) {
|
|||||||
],
|
],
|
||||||
});
|
});
|
||||||
const results = fuse.search(query);
|
const results = fuse.search(query);
|
||||||
if (!results.length) return { error: buildResultError('not_found', 'Selector', candidates) };
|
if (!results.length) return { error: buildResultError('not_found', 'User', candidates) };
|
||||||
|
|
||||||
const first = results[0];
|
const first = results[0];
|
||||||
const second = results[1];
|
const second = results[1];
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ const { createReasonCommand } = require('./commands/reason');
|
|||||||
const { createGoalCommand } = require('./commands/goal');
|
const { createGoalCommand } = require('./commands/goal');
|
||||||
const { createVerifyCommand } = require('./commands/verify');
|
const { createVerifyCommand } = require('./commands/verify');
|
||||||
const { createDeterCommand } = require('./commands/deter');
|
const { createDeterCommand } = require('./commands/deter');
|
||||||
|
const { createGainCommand } = require('./commands/gain');
|
||||||
const { createLightsCommand } = require('./commands/lights');
|
const { createLightsCommand } = require('./commands/lights');
|
||||||
const { createKickCommand } = require('./commands/kick');
|
const { createKickCommand } = require('./commands/kick');
|
||||||
const { createLiftCommand } = require('./commands/lift');
|
const { createLiftCommand } = require('./commands/lift');
|
||||||
@@ -47,6 +48,7 @@ function createCommandHandlers(deps) {
|
|||||||
const handleGoalCommand = createGoalCommand(deps);
|
const handleGoalCommand = createGoalCommand(deps);
|
||||||
const handleVerifyCommand = createVerifyCommand(deps);
|
const handleVerifyCommand = createVerifyCommand(deps);
|
||||||
const handleDeterCommand = createDeterCommand(deps);
|
const handleDeterCommand = createDeterCommand(deps);
|
||||||
|
const handleGainCommand = createGainCommand(deps);
|
||||||
const handleBridgeCommand = transportHandlers.bridge;
|
const handleBridgeCommand = transportHandlers.bridge;
|
||||||
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
||||||
const handleLightsCommand = createLightsCommand(deps);
|
const handleLightsCommand = createLightsCommand(deps);
|
||||||
@@ -97,7 +99,7 @@ function createCommandHandlers(deps) {
|
|||||||
// is included because its lock/unlock subcommands change room policy. Its
|
// is included because its lock/unlock subcommands change room policy. Its
|
||||||
// ordinary on/off/color actions are also intentionally restricted to a
|
// ordinary on/off/color actions are also intentionally restricted to a
|
||||||
// lockdown admin while the entire server is in lockdown.
|
// lockdown admin while the entire server is in lockdown.
|
||||||
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'lights', 'kick', 'lift', 'neato']);
|
const moderationActions = new Set(['lock', 'unlock', 'mode', 'goal', 'reason', 'verify', 'deter', 'gain', 'lights', 'kick', 'lift', 'neato']);
|
||||||
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
const isAccessModeCommand = commandDefinition?.permission === 'access-mode';
|
||||||
|
|
||||||
// Feature commands are public activities while access is open or managed
|
// Feature commands are public activities while access is open or managed
|
||||||
@@ -153,6 +155,8 @@ function createCommandHandlers(deps) {
|
|||||||
return handleVerifyCommand(request, tokens);
|
return handleVerifyCommand(request, tokens);
|
||||||
case 'deter':
|
case 'deter':
|
||||||
return handleDeterCommand(request, tokens);
|
return handleDeterCommand(request, tokens);
|
||||||
|
case 'gain':
|
||||||
|
return handleGainCommand(request, tokens);
|
||||||
default:
|
default:
|
||||||
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
return request.reply(formatHelp({ commandPrefix, timeStatusCommand, includeDiscord: request.transport === 'discord', isFeatureEnabled: deps.isFeatureEnabled }));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@
|
|||||||
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
// Scope: Supplies transport-neutral metadata; execution handlers remain focused on server operations.
|
||||||
const CATEGORIES = {
|
const CATEGORIES = {
|
||||||
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
system: { title: 'System', names: ['help', 'status', 'replay', 'time-status'] },
|
||||||
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter'] },
|
admin: { title: 'Admin', names: ['lock', 'unlock', 'mode', 'reason', 'goal', 'kick', 'verify', 'deter', 'gain'] },
|
||||||
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
|
features: { title: 'Features', names: ['lights', 'lift', 'neato'] },
|
||||||
discord: { title: 'Discord', names: ['bridge'] },
|
discord: { title: 'Discord', names: ['bridge'] },
|
||||||
};
|
};
|
||||||
@@ -46,6 +46,17 @@ function buildCommandRegistry(prefix, timeCommand) {
|
|||||||
access: 'Lockdown admin',
|
access: 'Lockdown admin',
|
||||||
permission: 'lockdown-admin',
|
permission: 'lockdown-admin',
|
||||||
},
|
},
|
||||||
|
gain: {
|
||||||
|
category: 'admin',
|
||||||
|
summary: 'Manage the VIP audio gain boost that raises a user\'s volume ceiling past the global gains.',
|
||||||
|
usage: [
|
||||||
|
`${prefix} gain list`,
|
||||||
|
`${prefix} gain grant <vip>`,
|
||||||
|
`${prefix} gain revoke <vip>`,
|
||||||
|
],
|
||||||
|
access: 'Admin',
|
||||||
|
permission: 'admin',
|
||||||
|
},
|
||||||
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
|
lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift <status|up|down>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'lift', unavailableLabel: 'Lift' },
|
||||||
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
|
neato: { category: 'features', summary: 'Show or control Neato.', usage: [`${prefix} neato <status|start|home|locate|clear-errors>`], access: 'Public unless server access is restricted', permission: 'access-mode', requiredFeature: 'neato', unavailableLabel: 'Neato' },
|
||||||
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
bridge: { category: 'discord', summary: 'Configure this Discord server chat bridge.', usage: [`${prefix} bridge`, `${prefix} bridge here <global|private>`, `${prefix} bridge mode <global|private>`, `${prefix} bridge off`], access: 'Discord server manager' },
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ const {
|
|||||||
getUserIdForSocket,
|
getUserIdForSocket,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
||||||
const { getButtonBoxState } = require('../buttonBoxService');
|
const { getButtonBoxState } = require('../buttonBoxService');
|
||||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||||
const {
|
const {
|
||||||
@@ -223,6 +223,7 @@ function buildSession(socket) {
|
|||||||
isVerified: Boolean(socket?.data?.isVerified),
|
isVerified: Boolean(socket?.data?.isVerified),
|
||||||
audioForward: getAudioForwardState(),
|
audioForward: getAudioForwardState(),
|
||||||
audioLevels: getAudioLevels(),
|
audioLevels: getAudioLevels(),
|
||||||
|
audioGains: getAudioGainStateForSocket(socket),
|
||||||
buttonBox: getButtonBoxState(),
|
buttonBox: getButtonBoxState(),
|
||||||
/*
|
/*
|
||||||
Inter-instance state is a read-only directory snapshot. It is included in
|
Inter-instance state is a read-only directory snapshot. It is included in
|
||||||
@@ -454,7 +455,18 @@ audioForwardEvents.on('change', () => {
|
|||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
audioLevelsEvents.on('change', () => {
|
audioLevelsEvents.on('change', ({ scope, userId } = {}) => {
|
||||||
|
/*
|
||||||
|
A user dragging their own volume slider only changes their own payload, so
|
||||||
|
it resyncs just that user's tabs. Admin gain and cap edits still change
|
||||||
|
everyone's ceiling and keep the full broadcast.
|
||||||
|
*/
|
||||||
|
if (scope === 'user' && userId) {
|
||||||
|
io.sockets.sockets.forEach((socket) => {
|
||||||
|
if (getUserIdForSocket(socket) === userId) syncSocket(socket);
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
syncAll();
|
syncAll();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -27,9 +27,11 @@ const {
|
|||||||
setVerified,
|
setVerified,
|
||||||
setDeterrence,
|
setDeterrence,
|
||||||
setMuted,
|
setMuted,
|
||||||
|
setAudioGainBoost,
|
||||||
listVerifiedUsers,
|
listVerifiedUsers,
|
||||||
listDeterredUsers,
|
listDeterredUsers,
|
||||||
listMutedUsers,
|
listMutedUsers,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
resolveUserBySelector,
|
resolveUserBySelector,
|
||||||
userToLegacyIdentityEntry,
|
userToLegacyIdentityEntry,
|
||||||
} = require('../identityService');
|
} = require('../identityService');
|
||||||
@@ -113,10 +115,17 @@ function refreshSocketIdentityFlags(socket) {
|
|||||||
operational chat/audio tools while preserving the flag for normal roles.
|
operational chat/audio tools while preserving the flag for normal roles.
|
||||||
*/
|
*/
|
||||||
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
|
socket.data.isMuted = isAdminRole(role) ? false : mutedByUser;
|
||||||
|
/*
|
||||||
|
Admins already control the global gain settings, so they carry the raised
|
||||||
|
audio ceiling implicitly for the same reason they carry verification: a
|
||||||
|
stored per-user grant should never be what gates an operator's own tools.
|
||||||
|
*/
|
||||||
|
socket.data.hasAudioGainBoost = verifiedByRole || Boolean(user.audioGainBoost?.enabled);
|
||||||
return {
|
return {
|
||||||
isVerified: socket.data.isVerified,
|
isVerified: socket.data.isVerified,
|
||||||
isDeterred: socket.data.isDeterred,
|
isDeterred: socket.data.isDeterred,
|
||||||
isMuted: socket.data.isMuted,
|
isMuted: socket.data.isMuted,
|
||||||
|
hasAudioGainBoost: socket.data.hasAudioGainBoost,
|
||||||
matchedRecordId: user.id,
|
matchedRecordId: user.id,
|
||||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||||
userId: user.id,
|
userId: user.id,
|
||||||
@@ -565,6 +574,53 @@ function setUserMute(selector, enabled, actor = null) {
|
|||||||
return userToLegacyIdentityEntry(user);
|
return userToLegacyIdentityEntry(user);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
Audio gain boost is a VIP-only grant. resolveUserBySelector's `includeVerified`
|
||||||
|
flag reads as an inclusion toggle but actually relaxes the verified filter, so
|
||||||
|
passing `false` is what restricts the nickname lookup to verified users. That
|
||||||
|
keeps an unverified visitor from being matched by a shared nickname and handed
|
||||||
|
a raised gain ceiling they are not eligible to use.
|
||||||
|
*/
|
||||||
|
function setUserAudioGainBoost(selector, enabled, actor = null) {
|
||||||
|
const resolved = resolveUserBySelector(selector, { includeVerified: false, includeDeterred: true });
|
||||||
|
if (resolved.error || !resolved.user) {
|
||||||
|
throw new Error(resolved.error === 'ambiguous_nickname'
|
||||||
|
? 'Nickname matches multiple VIPs.'
|
||||||
|
: 'VIP not found. Only verified users can be granted an audio gain boost.');
|
||||||
|
}
|
||||||
|
if (!resolved.user.verified?.enabled) {
|
||||||
|
throw new Error('Only verified VIPs can be granted an audio gain boost.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = setAudioGainBoost(resolved.user.id, {
|
||||||
|
enabled,
|
||||||
|
actor: actor ? String(actor) : null,
|
||||||
|
at: Date.now(),
|
||||||
|
});
|
||||||
|
refreshSocketsForUser(user.id);
|
||||||
|
publishEvent({
|
||||||
|
source: 'moderation',
|
||||||
|
type: enabled ? 'audio.gainBoostGranted' : 'audio.gainBoostRevoked',
|
||||||
|
payload: {
|
||||||
|
userId: user.id,
|
||||||
|
cookieUserId: user.cookieUserIds[0] || null,
|
||||||
|
nickname: user.nickname,
|
||||||
|
actor: actor ? String(actor) : null,
|
||||||
|
ts: Date.now(),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
emitChange(enabled ? 'audio_gain_boost_grant' : 'audio_gain_boost_revoke', { userId: user.id });
|
||||||
|
return userToLegacyIdentityEntry(user);
|
||||||
|
}
|
||||||
|
|
||||||
|
function grantAudioGainBoost(selector, actor = null) {
|
||||||
|
return setUserAudioGainBoost(selector, true, actor);
|
||||||
|
}
|
||||||
|
|
||||||
|
function revokeAudioGainBoost(selector, actor = null) {
|
||||||
|
return setUserAudioGainBoost(selector, false, actor);
|
||||||
|
}
|
||||||
|
|
||||||
function muteUser(selector, actor = null) {
|
function muteUser(selector, actor = null) {
|
||||||
return setUserMute(selector, true, actor);
|
return setUserMute(selector, true, actor);
|
||||||
}
|
}
|
||||||
@@ -696,9 +752,13 @@ module.exports = {
|
|||||||
undeterUser,
|
undeterUser,
|
||||||
muteUser,
|
muteUser,
|
||||||
unmuteUser,
|
unmuteUser,
|
||||||
|
listAudioGainBoostUsers,
|
||||||
|
grantAudioGainBoost,
|
||||||
|
revokeAudioGainBoost,
|
||||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||||
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||||
|
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
|
||||||
reevaluateSocketVerification,
|
reevaluateSocketVerification,
|
||||||
reevaluateSocketDeterrence,
|
reevaluateSocketDeterrence,
|
||||||
verificationEvents,
|
verificationEvents,
|
||||||
|
|||||||
@@ -18,6 +18,44 @@ const MODES = [
|
|||||||
{ key: 'lockdown', label: 'Lockdown' },
|
{ key: 'lockdown', label: 'Lockdown' },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
/*
|
||||||
|
The same three gain keys drive both the global levels and the VIP boost hard
|
||||||
|
caps, so both editors render from one list instead of six copied sliders.
|
||||||
|
*/
|
||||||
|
const GAIN_FIELDS = [
|
||||||
|
{ key: 'hornGain', label: 'Horn gain' },
|
||||||
|
{ key: 'ttsGain', label: 'TTS gain' },
|
||||||
|
{ key: 'forwardGain', label: 'Forward gain' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function normalizeGainDraft(source, fallback) {
|
||||||
|
const draft = {};
|
||||||
|
GAIN_FIELDS.forEach(({ key }) => {
|
||||||
|
draft[key] = Number.isFinite(source?.[key]) ? source[key] : fallback[key];
|
||||||
|
});
|
||||||
|
return draft;
|
||||||
|
}
|
||||||
|
|
||||||
|
function GainSlider({ label, value, onChange }) {
|
||||||
|
return (
|
||||||
|
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||||
|
<div className="flex items-center justify-between gap-0.5">
|
||||||
|
<span>{label}</span>
|
||||||
|
<span>{Number(value).toFixed(2)}x</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="4"
|
||||||
|
step="0.01"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
className="w-full accent-emerald-500"
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildPrivateSafetyDraft(rover) {
|
function buildPrivateSafetyDraft(rover) {
|
||||||
const safety = rover?.private?.safety || {};
|
const safety = rover?.private?.safety || {};
|
||||||
return {
|
return {
|
||||||
@@ -72,6 +110,7 @@ export default function AdminPanelContent() {
|
|||||||
updateAllRovers,
|
updateAllRovers,
|
||||||
rebootServer,
|
rebootServer,
|
||||||
setAudioLevels,
|
setAudioLevels,
|
||||||
|
setUserAudioGainCaps,
|
||||||
setPrivateSafety,
|
setPrivateSafety,
|
||||||
llmControl,
|
llmControl,
|
||||||
overseerControl,
|
overseerControl,
|
||||||
@@ -94,11 +133,13 @@ export default function AdminPanelContent() {
|
|||||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||||
const currentAudioLevels = session?.audioLevels || {};
|
const currentAudioLevels = session?.audioLevels || {};
|
||||||
const [audioLevelDraft, setAudioLevelDraft] = useState({
|
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
|
||||||
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
||||||
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
||||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
);
|
||||||
});
|
const [userGainCapDraft, setUserGainCapDraft] = useState(
|
||||||
|
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
|
||||||
|
);
|
||||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
|
||||||
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
|
||||||
|
|
||||||
@@ -299,6 +340,19 @@ export default function AdminPanelContent() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleUserGainCapDraft = (key) => (event) => {
|
||||||
|
const next = Number(event.target.value);
|
||||||
|
setUserGainCapDraft((current) => ({ ...(current || {}), [key]: Number.isFinite(next) ? next : 0 }));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUserGainCapsSave = async () => {
|
||||||
|
try {
|
||||||
|
await setUserAudioGainCaps(userGainCapDraft);
|
||||||
|
} catch (err) {
|
||||||
|
alert(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const handleTestRewardOverlay = async () => {
|
const handleTestRewardOverlay = async () => {
|
||||||
window.dispatchEvent(
|
window.dispatchEvent(
|
||||||
new CustomEvent('buttonBox:rewardRunLocalTest', {
|
new CustomEvent('buttonBox:rewardRunLocalTest', {
|
||||||
@@ -320,13 +374,15 @@ export default function AdminPanelContent() {
|
|||||||
}, [currentReason]);
|
}, [currentReason]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setAudioLevelDraft({
|
setAudioLevelDraft(normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }));
|
||||||
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
|
||||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
|
||||||
});
|
|
||||||
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
}, [currentAudioLevels.forwardGain, currentAudioLevels.hornGain, currentAudioLevels.ttsGain]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setUserGainCapDraft(normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [currentUserGainCaps.forwardGain, currentUserGainCaps.hornGain, currentUserGainCaps.ttsGain]);
|
||||||
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setPrivateSafetyDrafts((currentDrafts) => {
|
setPrivateSafetyDrafts((currentDrafts) => {
|
||||||
@@ -420,57 +476,48 @@ export default function AdminPanelContent() {
|
|||||||
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
|
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
{GAIN_FIELDS.map(({ key, label }) => (
|
||||||
<div className="flex items-center justify-between gap-0.5">
|
<GainSlider
|
||||||
<span>Horn gain</span>
|
key={key}
|
||||||
<span>{audioLevelDraft.hornGain.toFixed(2)}x</span>
|
label={label}
|
||||||
</div>
|
value={audioLevelDraft[key]}
|
||||||
<input
|
onChange={handleAudioLevelDraft(key)}
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="4"
|
|
||||||
step="0.01"
|
|
||||||
value={audioLevelDraft.hornGain}
|
|
||||||
onChange={handleAudioLevelDraft('hornGain')}
|
|
||||||
className="w-full accent-emerald-500"
|
|
||||||
/>
|
/>
|
||||||
</label>
|
))}
|
||||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
<p className="text-xs text-slate-500">
|
||||||
<div className="flex items-center justify-between gap-0.5">
|
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
|
||||||
<span>TTS gain</span>
|
</p>
|
||||||
<span>{audioLevelDraft.ttsGain.toFixed(2)}x</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="4"
|
|
||||||
step="0.01"
|
|
||||||
value={audioLevelDraft.ttsGain}
|
|
||||||
onChange={handleAudioLevelDraft('ttsGain')}
|
|
||||||
className="w-full accent-emerald-500"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
|
||||||
<div className="flex items-center justify-between gap-0.5">
|
|
||||||
<span>Forward gain</span>
|
|
||||||
<span>{audioLevelDraft.forwardGain.toFixed(2)}x</span>
|
|
||||||
</div>
|
|
||||||
<input
|
|
||||||
type="range"
|
|
||||||
min="0"
|
|
||||||
max="4"
|
|
||||||
step="0.01"
|
|
||||||
value={audioLevelDraft.forwardGain}
|
|
||||||
onChange={handleAudioLevelDraft('forwardGain')}
|
|
||||||
className="w-full accent-emerald-500"
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
<div className="flex gap-0.5 text-xs">
|
<div className="flex gap-0.5 text-xs">
|
||||||
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||||
Apply audio levels
|
Apply audio levels
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
|
<span>VIP gain boost hard caps</span>
|
||||||
|
{session?.audioLevels?.capsUpdatedAt ? (
|
||||||
|
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
{GAIN_FIELDS.map(({ key, label }) => (
|
||||||
|
<GainSlider
|
||||||
|
key={key}
|
||||||
|
label={label}
|
||||||
|
value={userGainCapDraft[key]}
|
||||||
|
onChange={handleUserGainCapDraft(key)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
<p className="text-xs text-slate-500">
|
||||||
|
Ceilings for VIPs granted the boost with <code>rs gain grant <vip></code>. A boost never lowers
|
||||||
|
someone's limit, so a cap below the global gain above has no effect.
|
||||||
|
</p>
|
||||||
|
<div className="flex gap-0.5 text-xs">
|
||||||
|
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
|
||||||
|
Apply boost caps
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div className="space-y-0.5">
|
<div className="space-y-0.5">
|
||||||
<div className="flex items-center justify-between text-xs text-slate-400">
|
<div className="flex items-center justify-between text-xs text-slate-400">
|
||||||
<span>Global objective</span>
|
<span>Global objective</span>
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import Tabs, { Tab, TabList, TabPanel, TabPanels } from '../Tabs/index.jsx';
|
|||||||
import SessionSnapshot from '../SessionSnapshot/index.jsx';
|
import SessionSnapshot from '../SessionSnapshot/index.jsx';
|
||||||
import SocketLogPanel from '../SocketLogPanel/index.jsx';
|
import SocketLogPanel from '../SocketLogPanel/index.jsx';
|
||||||
import CardFrame from '../CardFrame/index.jsx';
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
import VolumeSettingsCard from '../VolumeSettingsCard/index.jsx';
|
||||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||||
import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
|
import { useHudMapSetting } from '../../hooks/useHudMapSetting.js';
|
||||||
import { useSettingsNamespace } from '../../settings/index.js';
|
import { useSettingsNamespace } from '../../settings/index.js';
|
||||||
@@ -468,6 +469,9 @@ export default function SettingsPanel() {
|
|||||||
Lowers rover audio only while the main brush is running.
|
Lowers rover audio only while the main brush is running.
|
||||||
</SettingHelp>
|
</SettingHelp>
|
||||||
</CardFrame>
|
</CardFrame>
|
||||||
|
{/* Volume is server-backed rather than cookie-backed: it changes what the
|
||||||
|
rover plays for everyone in the room, so the server owns the limits. */}
|
||||||
|
<VolumeSettingsCard />
|
||||||
<CardFrame title="Connection" bodyClassName="space-y-1 p-1 text-sm">
|
<CardFrame title="Connection" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
<SettingRow>
|
<SettingRow>
|
||||||
<span className="font-semibold text-white">Transport</span>
|
<span className="font-semibold text-white">Transport</span>
|
||||||
|
|||||||
@@ -0,0 +1,141 @@
|
|||||||
|
// Volume Settings Card
|
||||||
|
// Purpose: Lets any user set their own horn, TTS, and mic-forward volume.
|
||||||
|
// Scope: Renders the server-resolved ceilings; the server still owns every limit.
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import CardFrame from '../CardFrame/index.jsx';
|
||||||
|
import { useSession } from '../../context/SessionContext.jsx';
|
||||||
|
import { trackAnalyticsEventThrottled } from '../../analytics/index.js';
|
||||||
|
|
||||||
|
/*
|
||||||
|
Sliders are a 0-1 fraction of whichever ceiling the server resolved for this
|
||||||
|
user, so the same three keys describe the fraction, the ceiling, and the gain
|
||||||
|
the rover will actually apply.
|
||||||
|
*/
|
||||||
|
const GAIN_FIELDS = [
|
||||||
|
{ key: 'hornGain', label: 'Horn volume' },
|
||||||
|
{ key: 'ttsGain', label: 'Text-to-speech volume' },
|
||||||
|
{ key: 'forwardGain', label: 'Microphone volume' },
|
||||||
|
];
|
||||||
|
|
||||||
|
// Dragging a range input fires continuously; persist once the user settles.
|
||||||
|
const COMMIT_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
function clampFraction(value, fallback = 1) {
|
||||||
|
const num = Number(value);
|
||||||
|
if (!Number.isFinite(num)) return fallback;
|
||||||
|
return Math.max(0, Math.min(1, num));
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeValues(raw) {
|
||||||
|
const out = {};
|
||||||
|
GAIN_FIELDS.forEach(({ key }) => {
|
||||||
|
out[key] = clampFraction(raw?.[key], 1);
|
||||||
|
});
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatGain(value) {
|
||||||
|
const num = Number(value);
|
||||||
|
return `${Number.isFinite(num) ? num.toFixed(2) : '0.00'}x`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VolumeSettingsCard() {
|
||||||
|
const { session, setUserAudioGains } = useSession();
|
||||||
|
const audioGains = session?.audioGains || null;
|
||||||
|
const serverValues = useMemo(() => normalizeValues(audioGains?.values), [audioGains?.values]);
|
||||||
|
const ceilings = audioGains?.ceilings || {};
|
||||||
|
const boostGranted = Boolean(audioGains?.boostGranted);
|
||||||
|
|
||||||
|
const [draft, setDraft] = useState(serverValues);
|
||||||
|
const [error, setError] = useState(null);
|
||||||
|
const commitTimerRef = useRef(null);
|
||||||
|
const pendingRef = useRef(null);
|
||||||
|
|
||||||
|
/*
|
||||||
|
The server is authoritative, so an accepted save or an admin-side change
|
||||||
|
resyncs the sliders. Comparing the serialized values keeps a resync from
|
||||||
|
fighting a drag that is already in flight.
|
||||||
|
*/
|
||||||
|
useEffect(() => {
|
||||||
|
if (pendingRef.current) return;
|
||||||
|
setDraft(serverValues);
|
||||||
|
}, [serverValues]);
|
||||||
|
|
||||||
|
const commit = useCallback(
|
||||||
|
async (next) => {
|
||||||
|
pendingRef.current = next;
|
||||||
|
try {
|
||||||
|
await setUserAudioGains(next);
|
||||||
|
setError(null);
|
||||||
|
} catch (err) {
|
||||||
|
setError(err?.message || 'Failed to save volume');
|
||||||
|
setDraft(serverValues);
|
||||||
|
} finally {
|
||||||
|
pendingRef.current = null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[serverValues, setUserAudioGains],
|
||||||
|
);
|
||||||
|
|
||||||
|
useEffect(() => () => {
|
||||||
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleChange = (key) => (event) => {
|
||||||
|
const next = clampFraction(event.target.value, 0);
|
||||||
|
const nextDraft = { ...draft, [key]: next };
|
||||||
|
setDraft(nextDraft);
|
||||||
|
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
|
||||||
|
commitTimerRef.current = setTimeout(() => commit(nextDraft), COMMIT_DEBOUNCE_MS);
|
||||||
|
trackAnalyticsEventThrottled(
|
||||||
|
'settings_change',
|
||||||
|
{ setting: key, value: next },
|
||||||
|
{ key: `volume:${key}`, throttleMs: 3 * 1000 },
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Sliders would be misleading before the first session sync lands.
|
||||||
|
if (!audioGains) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<CardFrame title="Volume" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
|
||||||
|
{GAIN_FIELDS.map(({ key, label }) => {
|
||||||
|
const ceiling = Number.isFinite(Number(ceilings[key])) ? Number(ceilings[key]) : 0;
|
||||||
|
const fraction = clampFraction(draft[key], 1);
|
||||||
|
const muted = ceiling <= 0;
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={key}
|
||||||
|
className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white"
|
||||||
|
>
|
||||||
|
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
|
||||||
|
<span className="min-w-0 font-semibold text-white">{label}</span>
|
||||||
|
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
|
||||||
|
{Math.round(fraction * 100)}% · {formatGain(fraction * ceiling)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="range"
|
||||||
|
min="0"
|
||||||
|
max="1"
|
||||||
|
step="0.01"
|
||||||
|
value={fraction}
|
||||||
|
onChange={handleChange(key)}
|
||||||
|
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
|
||||||
|
disabled={muted}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-slate-400">
|
||||||
|
{muted ? 'Muted by admin gain settings.' : `100% = ${formatGain(ceiling)} (your current limit)`}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
<p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">
|
||||||
|
{boostGranted
|
||||||
|
? 'You have a raised volume limit. 100% is the admin-set hard cap for boosted users rather than the normal global gain.'
|
||||||
|
: 'Your limit is the global gain an admin has set. Admins can raise it per user.'}
|
||||||
|
</p>
|
||||||
|
{error && <p className="mx-auto w-full max-w-lg text-xs text-rose-300">{error}</p>}
|
||||||
|
</CardFrame>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -422,6 +422,13 @@ export function SessionProvider({ children }) {
|
|||||||
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
||||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||||
|
/*
|
||||||
|
Personal volume is a 0-1 fraction of whichever ceiling the server has
|
||||||
|
resolved for this user, so the browser never sends an absolute gain and
|
||||||
|
cannot widen its own limits.
|
||||||
|
*/
|
||||||
|
setUserAudioGains: (gains = {}) => emitWithAck('audioLevels:setUserGains', gains),
|
||||||
|
setUserAudioGainCaps: (caps = {}) => emitWithAck('audioLevels:setUserCaps', caps),
|
||||||
setPrivateSafety: (roverId, safety = {}) =>
|
setPrivateSafety: (roverId, safety = {}) =>
|
||||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||||
|
|||||||
Reference in New Issue
Block a user