mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -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 { isAdmin } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const {
|
||||
GAIN_KEYS,
|
||||
clampGain,
|
||||
clampFraction,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
} = require('./gainMath');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
const DATA_DIR = resolveDataDir();
|
||||
const STORE_PATH = resolveDataPath('audio-levels.json');
|
||||
const config = loadConfig();
|
||||
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 = {
|
||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
|
||||
};
|
||||
|
||||
function clampGain(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(4, num));
|
||||
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
||||
return normalizeGainSet(raw, fallback);
|
||||
}
|
||||
|
||||
function normalizeStore(raw = {}) {
|
||||
@@ -34,8 +63,11 @@ function normalizeStore(raw = {}) {
|
||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
|
||||
updatedAt: Number.isFinite(raw.updatedAt) ? raw.updatedAt : 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,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
userGainCaps: { ...current.userGainCaps },
|
||||
updatedAt: current.updatedAt,
|
||||
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', {
|
||||
reason,
|
||||
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) {
|
||||
if (!roverId) return;
|
||||
const record = roverManager.rovers.get(roverId);
|
||||
@@ -90,7 +198,7 @@ function pushLevelsToRover(roverId) {
|
||||
try {
|
||||
issueCommand(roverId, {
|
||||
type: 'audioLevels',
|
||||
audioLevels: getAudioLevels(),
|
||||
audioLevels: resolveLevelsForRover(roverId),
|
||||
});
|
||||
} catch (err) {
|
||||
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) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
@@ -121,12 +234,83 @@ function setAudioLevels(input = {}, actor = null) {
|
||||
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 } = {}) => {
|
||||
if (action === 'upsert' && 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) => {
|
||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||
cb({ success: true, levels: getAudioLevels() });
|
||||
@@ -144,13 +328,51 @@ io.on('connection', (socket) => {
|
||||
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();
|
||||
|
||||
module.exports = {
|
||||
GAIN_KEYS,
|
||||
USER_GAIN_CAP_DEFAULTS,
|
||||
getAudioLevels,
|
||||
setAudioLevels,
|
||||
getUserGainCaps,
|
||||
setUserGainCaps,
|
||||
getUserGains,
|
||||
setUserGains,
|
||||
getGainCeilingsForSocket,
|
||||
getEffectiveLevelsForSocket,
|
||||
getAudioGainStateForSocket,
|
||||
pushLevelsToRover,
|
||||
audioLevelsEvents,
|
||||
};
|
||||
|
||||
@@ -22,6 +22,9 @@ const {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
} = require('../verificationService');
|
||||
const { publishEvent } = require('../eventBus');
|
||||
const assignmentService = require('../assignmentService');
|
||||
@@ -184,6 +187,9 @@ async function runChatTextCommand({ text, socket, sendSystemMessage }) {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
sanitizeMentions,
|
||||
sendToChannel: null,
|
||||
isAdminUser: (id) => String(id) === String(socket.id) && isAdmin(socket),
|
||||
|
||||
@@ -46,6 +46,9 @@ const {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
} = require('../verificationService');
|
||||
const {
|
||||
attachDmMessage: attachPrivateAccessDmMessage,
|
||||
@@ -250,6 +253,9 @@ const commandDependencies = {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
sanitizeMentions,
|
||||
sendToChannel: channelIO.sendToChannel,
|
||||
isAdminUser,
|
||||
|
||||
@@ -17,7 +17,7 @@ const USER_ID_RE = /^usr_[a-f0-9]{32}$/;
|
||||
const DB_PATH = resolveDataPath('identity.sqlite');
|
||||
const LEGACY_VERIFICATION_PATH = resolveDataPath('verified-users.json');
|
||||
const LEGACY_BARCODE_PATH = resolveDataPath('barcode-games.json');
|
||||
const STORE_VERSION = 2;
|
||||
const STORE_VERSION = 3;
|
||||
const identityEvents = new EventEmitter();
|
||||
|
||||
let db = null;
|
||||
@@ -169,7 +169,10 @@ function ensureSchema(conn) {
|
||||
deterrence_by text,
|
||||
muted_enabled integer not null default 0,
|
||||
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 (
|
||||
@@ -220,7 +223,8 @@ function ensureSchema(conn) {
|
||||
/*
|
||||
SQLite's `create table if not exists` leaves an existing table untouched.
|
||||
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(
|
||||
conn.prepare('pragma table_info(user_status)').all().map((column) => column.name),
|
||||
@@ -234,6 +238,15 @@ function ensureSchema(conn) {
|
||||
if (!statusColumns.has('muted_by')) {
|
||||
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()) {
|
||||
@@ -418,6 +431,7 @@ function setSocketIdentityState(socket, user, identity = {}) {
|
||||
socket.data.isDeterred = Boolean(user.deterrence?.enabled);
|
||||
socket.data.deterredRecordId = user.deterrence?.enabled ? user.id : null;
|
||||
socket.data.isMuted = Boolean(user.deterrence?.muted);
|
||||
socket.data.hasAudioGainBoost = Boolean(user.audioGainBoost?.enabled);
|
||||
}
|
||||
|
||||
function identifySocket(socket, payload = {}) {
|
||||
@@ -509,6 +523,11 @@ function getUserById(userId, { conn = getDb(), includeFeatures = true } = {}) {
|
||||
mutedAt: status.muted_at || 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,
|
||||
};
|
||||
}
|
||||
@@ -726,21 +745,48 @@ function setMuted(userId, { enabled = true, actor = null, at = nowMs() } = {}) {
|
||||
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) {
|
||||
return Boolean(socket?.data?.isVerified);
|
||||
}
|
||||
|
||||
function hasAudioGainBoost(socket) {
|
||||
return Boolean(socket?.data?.hasAudioGainBoost);
|
||||
}
|
||||
|
||||
function isDeterred(socket) {
|
||||
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();
|
||||
let sql = 'select users.id from users join user_status on user_status.user_id = users.id';
|
||||
const where = [];
|
||||
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 (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 ')}`;
|
||||
sql += ' order by users.updated_at desc';
|
||||
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),
|
||||
mutedAt: user.deterrence?.mutedAt || 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);
|
||||
}
|
||||
|
||||
function listAudioGainBoostUsers() {
|
||||
return listUsers({ audioGainBoost: true }).map(userToLegacyIdentityEntry);
|
||||
}
|
||||
|
||||
function resolveUserBySelector(selector, { includeDeterred = true, includeVerified = true } = {}) {
|
||||
const value = String(selector || '').trim();
|
||||
if (!value) return { error: 'selector_required' };
|
||||
@@ -1031,11 +1084,14 @@ module.exports = {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setAudioGainBoost,
|
||||
isVerified,
|
||||
isDeterred,
|
||||
hasAudioGainBoost,
|
||||
listVerifiedUsers,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
listAudioGainBoostUsers,
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
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);
|
||||
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 second = results[1];
|
||||
|
||||
@@ -8,6 +8,7 @@ const { createReasonCommand } = require('./commands/reason');
|
||||
const { createGoalCommand } = require('./commands/goal');
|
||||
const { createVerifyCommand } = require('./commands/verify');
|
||||
const { createDeterCommand } = require('./commands/deter');
|
||||
const { createGainCommand } = require('./commands/gain');
|
||||
const { createLightsCommand } = require('./commands/lights');
|
||||
const { createKickCommand } = require('./commands/kick');
|
||||
const { createLiftCommand } = require('./commands/lift');
|
||||
@@ -47,6 +48,7 @@ function createCommandHandlers(deps) {
|
||||
const handleGoalCommand = createGoalCommand(deps);
|
||||
const handleVerifyCommand = createVerifyCommand(deps);
|
||||
const handleDeterCommand = createDeterCommand(deps);
|
||||
const handleGainCommand = createGainCommand(deps);
|
||||
const handleBridgeCommand = transportHandlers.bridge;
|
||||
const handleTimeStatusCommand = transportHandlers.timeStatus;
|
||||
const handleLightsCommand = createLightsCommand(deps);
|
||||
@@ -97,7 +99,7 @@ function createCommandHandlers(deps) {
|
||||
// is included because its lock/unlock subcommands change room policy. Its
|
||||
// ordinary on/off/color actions are also intentionally restricted to a
|
||||
// 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';
|
||||
|
||||
// Feature commands are public activities while access is open or managed
|
||||
@@ -153,6 +155,8 @@ function createCommandHandlers(deps) {
|
||||
return handleVerifyCommand(request, tokens);
|
||||
case 'deter':
|
||||
return handleDeterCommand(request, tokens);
|
||||
case 'gain':
|
||||
return handleGainCommand(request, tokens);
|
||||
default:
|
||||
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.
|
||||
const CATEGORIES = {
|
||||
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'] },
|
||||
discord: { title: 'Discord', names: ['bridge'] },
|
||||
};
|
||||
@@ -46,6 +46,17 @@ function buildCommandRegistry(prefix, timeCommand) {
|
||||
access: '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' },
|
||||
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' },
|
||||
|
||||
@@ -53,7 +53,7 @@ const {
|
||||
getUserIdForSocket,
|
||||
} = require('../identityService');
|
||||
const { getAudioForwardState, audioForwardEvents } = require('../audioForwardService');
|
||||
const { getAudioLevels, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getAudioLevels, getAudioGainStateForSocket, audioLevelsEvents } = require('../audioLevelsService');
|
||||
const { getButtonBoxState } = require('../buttonBoxService');
|
||||
const { getState: getInterInstanceState, interInstanceEvents } = require('../interInstanceService');
|
||||
const {
|
||||
@@ -223,6 +223,7 @@ function buildSession(socket) {
|
||||
isVerified: Boolean(socket?.data?.isVerified),
|
||||
audioForward: getAudioForwardState(),
|
||||
audioLevels: getAudioLevels(),
|
||||
audioGains: getAudioGainStateForSocket(socket),
|
||||
buttonBox: getButtonBoxState(),
|
||||
/*
|
||||
Inter-instance state is a read-only directory snapshot. It is included in
|
||||
@@ -454,7 +455,18 @@ audioForwardEvents.on('change', () => {
|
||||
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();
|
||||
});
|
||||
|
||||
|
||||
@@ -27,9 +27,11 @@ const {
|
||||
setVerified,
|
||||
setDeterrence,
|
||||
setMuted,
|
||||
setAudioGainBoost,
|
||||
listVerifiedUsers,
|
||||
listDeterredUsers,
|
||||
listMutedUsers,
|
||||
listAudioGainBoostUsers,
|
||||
resolveUserBySelector,
|
||||
userToLegacyIdentityEntry,
|
||||
} = require('../identityService');
|
||||
@@ -113,10 +115,17 @@ function refreshSocketIdentityFlags(socket) {
|
||||
operational chat/audio tools while preserving the flag for normal roles.
|
||||
*/
|
||||
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 {
|
||||
isVerified: socket.data.isVerified,
|
||||
isDeterred: socket.data.isDeterred,
|
||||
isMuted: socket.data.isMuted,
|
||||
hasAudioGainBoost: socket.data.hasAudioGainBoost,
|
||||
matchedRecordId: user.id,
|
||||
reason: socket.data.isVerified ? 'matched' : 'no_match',
|
||||
userId: user.id,
|
||||
@@ -565,6 +574,53 @@ function setUserMute(selector, enabled, actor = null) {
|
||||
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) {
|
||||
return setUserMute(selector, true, actor);
|
||||
}
|
||||
@@ -696,9 +752,13 @@ module.exports = {
|
||||
undeterUser,
|
||||
muteUser,
|
||||
unmuteUser,
|
||||
listAudioGainBoostUsers,
|
||||
grantAudioGainBoost,
|
||||
revokeAudioGainBoost,
|
||||
isVerified: (socket) => Boolean(socket?.data?.isVerified),
|
||||
isDeterred: (socket) => Boolean(socket?.data?.isDeterred),
|
||||
isMuted: (socket) => Boolean(socket?.data?.isMuted),
|
||||
hasAudioGainBoost: (socket) => Boolean(socket?.data?.hasAudioGainBoost),
|
||||
reevaluateSocketVerification,
|
||||
reevaluateSocketDeterrence,
|
||||
verificationEvents,
|
||||
|
||||
@@ -18,6 +18,44 @@ const MODES = [
|
||||
{ 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) {
|
||||
const safety = rover?.private?.safety || {};
|
||||
return {
|
||||
@@ -72,6 +110,7 @@ export default function AdminPanelContent() {
|
||||
updateAllRovers,
|
||||
rebootServer,
|
||||
setAudioLevels,
|
||||
setUserAudioGainCaps,
|
||||
setPrivateSafety,
|
||||
llmControl,
|
||||
overseerControl,
|
||||
@@ -94,11 +133,13 @@ export default function AdminPanelContent() {
|
||||
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
|
||||
const [reasonDraft, setReasonDraft] = useState(currentReason);
|
||||
const currentAudioLevels = session?.audioLevels || {};
|
||||
const [audioLevelDraft, setAudioLevelDraft] = useState({
|
||||
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
||||
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||
});
|
||||
const currentUserGainCaps = currentAudioLevels.userGainCaps || {};
|
||||
const [audioLevelDraft, setAudioLevelDraft] = useState(
|
||||
() => normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }),
|
||||
);
|
||||
const [userGainCapDraft, setUserGainCapDraft] = useState(
|
||||
() => normalizeGainDraft(currentUserGainCaps, { hornGain: 0.5, ttsGain: 0.8, forwardGain: 0.4 }),
|
||||
);
|
||||
const [privateSafetyDrafts, setPrivateSafetyDrafts] = 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 () => {
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('buttonBox:rewardRunLocalTest', {
|
||||
@@ -320,13 +374,15 @@ export default function AdminPanelContent() {
|
||||
}, [currentReason]);
|
||||
|
||||
useEffect(() => {
|
||||
setAudioLevelDraft({
|
||||
hornGain: Number.isFinite(currentAudioLevels.hornGain) ? currentAudioLevels.hornGain : 1,
|
||||
ttsGain: Number.isFinite(currentAudioLevels.ttsGain) ? currentAudioLevels.ttsGain : 1,
|
||||
forwardGain: Number.isFinite(currentAudioLevels.forwardGain) ? currentAudioLevels.forwardGain : 1,
|
||||
});
|
||||
setAudioLevelDraft(normalizeGainDraft(currentAudioLevels, { hornGain: 1, ttsGain: 1, forwardGain: 1 }));
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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(() => {
|
||||
setPrivateSafetyDrafts((currentDrafts) => {
|
||||
@@ -420,57 +476,48 @@ export default function AdminPanelContent() {
|
||||
<span>Updated {new Date(session.audioLevels.updatedAt).toLocaleString()}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>Horn gain</span>
|
||||
<span>{audioLevelDraft.hornGain.toFixed(2)}x</span>
|
||||
</div>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="4"
|
||||
step="0.01"
|
||||
value={audioLevelDraft.hornGain}
|
||||
onChange={handleAudioLevelDraft('hornGain')}
|
||||
className="w-full accent-emerald-500"
|
||||
{GAIN_FIELDS.map(({ key, label }) => (
|
||||
<GainSlider
|
||||
key={key}
|
||||
label={label}
|
||||
value={audioLevelDraft[key]}
|
||||
onChange={handleAudioLevelDraft(key)}
|
||||
/>
|
||||
</label>
|
||||
<label className="grid gap-0.5 text-xs text-slate-200">
|
||||
<div className="flex items-center justify-between gap-0.5">
|
||||
<span>TTS gain</span>
|
||||
<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>
|
||||
))}
|
||||
<p className="text-xs text-slate-500">
|
||||
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
|
||||
</p>
|
||||
<div className="flex gap-0.5 text-xs">
|
||||
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
|
||||
Apply audio levels
|
||||
</button>
|
||||
</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="flex items-center justify-between text-xs text-slate-400">
|
||||
<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 SocketLogPanel from '../SocketLogPanel/index.jsx';
|
||||
import CardFrame from '../CardFrame/index.jsx';
|
||||
import VolumeSettingsCard from '../VolumeSettingsCard/index.jsx';
|
||||
import KeyPill from '../vip/VipAudioUploadCard/KeyPill.jsx';
|
||||
import { useHudMapSetting } from '../../hooks/useHudMapSetting.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.
|
||||
</SettingHelp>
|
||||
</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">
|
||||
<SettingRow>
|
||||
<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 }),
|
||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||
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 = {}) =>
|
||||
emitWithAck('session:privateSafety:set', { roverId, safety }),
|
||||
ptzClaim: () => emitWithAck('ptzCamera:claim'),
|
||||
|
||||
Reference in New Issue
Block a user