diff --git a/server/src/services/audioLevelsService/gainMath.js b/server/src/services/audioLevelsService/gainMath.js new file mode 100644 index 00000000..aeecfa25 --- /dev/null +++ b/server/src/services/audioLevelsService/gainMath.js @@ -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, +}; diff --git a/server/src/services/audioLevelsService/gainMath.test.js b/server/src/services/audioLevelsService/gainMath.test.js new file mode 100644 index 00000000..3ad9af6d --- /dev/null +++ b/server/src/services/audioLevelsService/gainMath.test.js @@ -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); +}); diff --git a/server/src/services/audioLevelsService/index.js b/server/src/services/audioLevelsService/index.js index 3308b580..e8af8f5a 100644 --- a/server/src/services/audioLevelsService/index.js +++ b/server/src/services/audioLevelsService/index.js @@ -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, }; diff --git a/server/src/services/chatService/textCommands.js b/server/src/services/chatService/textCommands.js index 1776282d..8c15d39b 100644 --- a/server/src/services/chatService/textCommands.js +++ b/server/src/services/chatService/textCommands.js @@ -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), diff --git a/server/src/services/discordBotService/index.js b/server/src/services/discordBotService/index.js index fa2ae6ac..7a671993 100644 --- a/server/src/services/discordBotService/index.js +++ b/server/src/services/discordBotService/index.js @@ -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, diff --git a/server/src/services/identityService/index.js b/server/src/services/identityService/index.js index 09d7e55a..47a86110 100644 --- a/server/src/services/identityService/index.js +++ b/server/src/services/identityService/index.js @@ -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, diff --git a/server/src/services/operatorCommandService/commands/gain.js b/server/src/services/operatorCommandService/commands/gain.js new file mode 100644 index 00000000..7b2a96ac --- /dev/null +++ b/server/src/services/operatorCommandService/commands/gain.js @@ -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} \``; + } + + /* + 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 \`, or \`${commandPrefix} gain revoke \`.`, + allowedMentions: plain, + }); + }; +} + +module.exports = { createGainCommand }; diff --git a/server/src/services/operatorCommandService/commands/gain.test.js b/server/src/services/operatorCommandService/commands/gain.test.js new file mode 100644 index 00000000..d4ff6e4e --- /dev/null +++ b/server/src/services/operatorCommandService/commands/gain.test.js @@ -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/); +}); diff --git a/server/src/services/operatorCommandService/commands/resolvers.js b/server/src/services/operatorCommandService/commands/resolvers.js index b8ee2b01..ab412d76 100644 --- a/server/src/services/operatorCommandService/commands/resolvers.js +++ b/server/src/services/operatorCommandService/commands/resolvers.js @@ -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]; diff --git a/server/src/services/operatorCommandService/index.js b/server/src/services/operatorCommandService/index.js index 8a0b63d5..df6aa2c6 100644 --- a/server/src/services/operatorCommandService/index.js +++ b/server/src/services/operatorCommandService/index.js @@ -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 })); } diff --git a/server/src/services/operatorCommandService/registry.js b/server/src/services/operatorCommandService/registry.js index 883dcd28..c33e1256 100644 --- a/server/src/services/operatorCommandService/registry.js +++ b/server/src/services/operatorCommandService/registry.js @@ -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 `, + `${prefix} gain revoke `, + ], + access: 'Admin', + permission: 'admin', + }, lift: { category: 'features', summary: 'Show or move the lift.', usage: [`${prefix} lift `], 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 `], 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 `, `${prefix} bridge mode `, `${prefix} bridge off`], access: 'Discord server manager' }, diff --git a/server/src/services/sessionService/index.js b/server/src/services/sessionService/index.js index f10c0e67..ec2ad0f8 100644 --- a/server/src/services/sessionService/index.js +++ b/server/src/services/sessionService/index.js @@ -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(); }); diff --git a/server/src/services/verificationService/index.js b/server/src/services/verificationService/index.js index c40de827..7c8d4008 100644 --- a/server/src/services/verificationService/index.js +++ b/server/src/services/verificationService/index.js @@ -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, diff --git a/webui/src/components/AdminPanel/AdminPanelContent.jsx b/webui/src/components/AdminPanel/AdminPanelContent.jsx index 3742c80b..a8f91921 100644 --- a/webui/src/components/AdminPanel/AdminPanelContent.jsx +++ b/webui/src/components/AdminPanel/AdminPanelContent.jsx @@ -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 ( + + ); +} + 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() { Updated {new Date(session.audioLevels.updatedAt).toLocaleString()} ) : null} - - - + ))} +

+ These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them. +

+
+
+ VIP gain boost hard caps + {session?.audioLevels?.capsUpdatedAt ? ( + Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()} + ) : null} +
+ {GAIN_FIELDS.map(({ key, label }) => ( + + ))} +

+ Ceilings for VIPs granted the boost with rs gain grant <vip>. A boost never lowers + someone's limit, so a cap below the global gain above has no effect. +

+
+ +
+
Global objective diff --git a/webui/src/components/SettingsPanel/index.jsx b/webui/src/components/SettingsPanel/index.jsx index 16c790e1..266061e3 100644 --- a/webui/src/components/SettingsPanel/index.jsx +++ b/webui/src/components/SettingsPanel/index.jsx @@ -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. + {/* 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. */} + Transport diff --git a/webui/src/components/VolumeSettingsCard/index.jsx b/webui/src/components/VolumeSettingsCard/index.jsx new file mode 100644 index 00000000..46e01ba3 --- /dev/null +++ b/webui/src/components/VolumeSettingsCard/index.jsx @@ -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 ( + + {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 ( + + ); + })} +

+ {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.'} +

+ {error &&

{error}

} +
+ ); +} diff --git a/webui/src/context/SessionContext.jsx b/webui/src/context/SessionContext.jsx index a3185245..116970c7 100644 --- a/webui/src/context/SessionContext.jsx +++ b/webui/src/context/SessionContext.jsx @@ -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'),