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:
legop3
2026-07-28 23:55:29 -04:00
committed by GitHub
17 changed files with 1021 additions and 69 deletions
@@ -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);
});
+228 -6
View File
@@ -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,
+60 -4
View File
@@ -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' },
+14 -2
View File
@@ -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 &lt;vip&gt;</code>. A boost never lowers
someone&apos;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>
);
}
+7
View File
@@ -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'),