mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
moving audio gain perms around
This commit is contained in:
@@ -1,79 +1,62 @@
|
||||
// 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.
|
||||
// Audio Adjustment Math
|
||||
// Purpose: Converts signed browser percentages into server-enforced rover gain multipliers.
|
||||
// Scope: Contains no IO or identity logic so the adjustment policy can be tested independently.
|
||||
const ADJUSTMENT_FIELDS = [
|
||||
{ gainKey: 'hornGain', percentKey: 'hornPercent' },
|
||||
{ gainKey: 'ttsGain', percentKey: 'ttsPercent' },
|
||||
{ gainKey: 'forwardGain', percentKey: 'forwardPercent' },
|
||||
];
|
||||
const MIN_GAIN = 0;
|
||||
const MAX_GAIN = 4;
|
||||
const MIN_ADJUSTMENT_PERCENT = -100;
|
||||
const MAX_ADJUSTMENT_PERCENT = 100;
|
||||
|
||||
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));
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.max(MIN_GAIN, Math.min(MAX_GAIN, number));
|
||||
}
|
||||
|
||||
function clampFraction(value, fallback = 1) {
|
||||
const num = Number(value);
|
||||
if (!Number.isFinite(num)) return fallback;
|
||||
return Math.max(0, Math.min(1, num));
|
||||
function clampMaximumAdjustmentPercent(value, fallback = 50) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return fallback;
|
||||
return Math.round(Math.max(0, Math.min(MAX_ADJUSTMENT_PERCENT, number)));
|
||||
}
|
||||
|
||||
function normalizeUserGains(raw = {}) {
|
||||
const out = {};
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
out[key] = clampFraction(raw?.[key], 1);
|
||||
function clampAdjustmentPercent(value, maximum = 0) {
|
||||
const number = Number(value);
|
||||
if (!Number.isFinite(number)) return 0;
|
||||
const limit = clampMaximumAdjustmentPercent(maximum, 0);
|
||||
return Math.round(Math.max(-limit, Math.min(limit, number)));
|
||||
}
|
||||
|
||||
function normalizeAdjustments(raw = {}, maximum = 0) {
|
||||
const normalized = {};
|
||||
ADJUSTMENT_FIELDS.forEach(({ percentKey }) => {
|
||||
normalized[percentKey] = clampAdjustmentPercent(raw?.[percentKey], maximum);
|
||||
});
|
||||
return out;
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeGainSet(raw = {}, fallback = {}) {
|
||||
const out = {};
|
||||
GAIN_KEYS.forEach((key) => {
|
||||
out[key] = clampGain(raw?.[key], clampGain(fallback?.[key], 1));
|
||||
function applyAdjustments(baseLevels = {}, adjustments = {}) {
|
||||
const effective = {};
|
||||
ADJUSTMENT_FIELDS.forEach(({ gainKey, percentKey }) => {
|
||||
const base = clampGain(baseLevels?.[gainKey], 0);
|
||||
const percentage = Math.max(MIN_ADJUSTMENT_PERCENT, Math.min(MAX_ADJUSTMENT_PERCENT, Number(adjustments?.[percentKey]) || 0));
|
||||
effective[gainKey] = clampGain(base * (1 + percentage / 100), 0);
|
||||
});
|
||||
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;
|
||||
return effective;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
GAIN_KEYS,
|
||||
ADJUSTMENT_FIELDS,
|
||||
MIN_GAIN,
|
||||
MAX_GAIN,
|
||||
MIN_ADJUSTMENT_PERCENT,
|
||||
MAX_ADJUSTMENT_PERCENT,
|
||||
clampGain,
|
||||
clampFraction,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
clampMaximumAdjustmentPercent,
|
||||
clampAdjustmentPercent,
|
||||
normalizeAdjustments,
|
||||
applyAdjustments,
|
||||
};
|
||||
|
||||
@@ -1,85 +1,37 @@
|
||||
// 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.
|
||||
// Audio Adjustment Math Tests
|
||||
// Purpose: Pins percentage clamping and conversion independently of sockets, identity, and rover IO.
|
||||
// Scope: Covers only the pure rules used by audioLevelsService.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const {
|
||||
clampFraction,
|
||||
clampGain,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
} = require('./gainMath');
|
||||
const { clampMaximumAdjustmentPercent, normalizeAdjustments, applyAdjustments } = 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 configured range is a whole percentage from zero through one hundred', () => {
|
||||
assert.equal(clampMaximumAdjustmentPercent(-5), 0);
|
||||
assert.equal(clampMaximumAdjustmentPercent(32.6), 33);
|
||||
assert.equal(clampMaximumAdjustmentPercent(500), 100);
|
||||
});
|
||||
|
||||
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('each browser percentage is clamped equally in both directions', () => {
|
||||
assert.deepEqual(normalizeAdjustments({ hornPercent: -80, ttsPercent: 10, forwardPercent: 90 }, 40), {
|
||||
hornPercent: -40,
|
||||
ttsPercent: 10,
|
||||
forwardPercent: 40,
|
||||
});
|
||||
});
|
||||
|
||||
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('signed percentages adjust each server base gain', () => {
|
||||
assert.deepEqual(
|
||||
applyAdjustments(
|
||||
{ hornGain: 1, ttsGain: 2, forwardGain: 0.5 },
|
||||
{ hornPercent: -25, ttsPercent: 25, forwardPercent: 40 },
|
||||
),
|
||||
{ hornGain: 0.75, ttsGain: 2.5, forwardGain: 0.7 },
|
||||
);
|
||||
});
|
||||
|
||||
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);
|
||||
test('effective gains remain inside the rover hard bounds', () => {
|
||||
assert.deepEqual(
|
||||
applyAdjustments({ hornGain: 4, ttsGain: 0, forwardGain: 3 }, { hornPercent: 100, ttsPercent: -100, forwardPercent: 100 }),
|
||||
{ hornGain: 4, ttsGain: 0, forwardGain: 4 },
|
||||
);
|
||||
});
|
||||
|
||||
@@ -7,18 +7,16 @@ const io = require('../../globals/io');
|
||||
const logger = require('../../globals/logger').child('audioLevelsService');
|
||||
const { loadConfig } = require('../../helpers/configLoader');
|
||||
const { resolveDataDir, resolveDataPath } = require('../../helpers/dataPaths');
|
||||
const { isAdmin } = require('../roleService');
|
||||
const { isAdmin, roleEvents } = require('../roleService');
|
||||
const roverManager = require('../roverManager');
|
||||
const { getFeatureState, setFeatureState, getUserIdForSocket } = require('../identityService');
|
||||
const { identityEvents, getUserIdForSocket, hasUserPermission } = require('../identityService');
|
||||
const { issueCommand } = require('../commandService');
|
||||
const {
|
||||
GAIN_KEYS,
|
||||
ADJUSTMENT_FIELDS,
|
||||
clampGain,
|
||||
clampFraction,
|
||||
normalizeUserGains,
|
||||
normalizeGainSet,
|
||||
resolveCeilings,
|
||||
applyCeilings,
|
||||
clampMaximumAdjustmentPercent,
|
||||
normalizeAdjustments,
|
||||
applyAdjustments,
|
||||
} = require('./gainMath');
|
||||
|
||||
const audioLevelsEvents = new EventEmitter();
|
||||
@@ -26,48 +24,32 @@ 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 PERSONAL_ADJUSTMENT_PERMISSION = 'audio.personalAdjustment';
|
||||
const DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT = 50;
|
||||
|
||||
const DEFAULTS = {
|
||||
hornGain: clampGain(configuredDefaults.hornGain, 1),
|
||||
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
|
||||
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
|
||||
userGainCaps: normalizeGainSet(configuredUserCaps, USER_GAIN_CAP_DEFAULTS),
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||
configuredDefaults.maxPersonalAdjustmentPercent,
|
||||
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||
),
|
||||
};
|
||||
|
||||
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
|
||||
return normalizeGainSet(raw, fallback);
|
||||
}
|
||||
|
||||
function normalizeStore(raw = {}) {
|
||||
return {
|
||||
hornGain: clampGain(raw.hornGain, DEFAULTS.hornGain),
|
||||
ttsGain: clampGain(raw.ttsGain, DEFAULTS.ttsGain),
|
||||
forwardGain: clampGain(raw.forwardGain, DEFAULTS.forwardGain),
|
||||
userGainCaps: normalizeUserGainCaps(raw.userGainCaps),
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(
|
||||
raw.maxPersonalAdjustmentPercent,
|
||||
DEFAULTS.maxPersonalAdjustmentPercent,
|
||||
),
|
||||
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,
|
||||
adjustmentRangeUpdatedAt: Number.isFinite(raw.adjustmentRangeUpdatedAt) ? raw.adjustmentRangeUpdatedAt : null,
|
||||
adjustmentRangeUpdatedBy: typeof raw.adjustmentRangeUpdatedBy === 'string' ? raw.adjustmentRangeUpdatedBy : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -78,6 +60,11 @@ function loadState() {
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(STORE_PATH, 'utf8'));
|
||||
state = normalizeStore(raw);
|
||||
if (Object.prototype.hasOwnProperty.call(raw, 'userGainCaps')) {
|
||||
// Rewrite once so the retired VIP-cap object does not linger beside the
|
||||
// new percentage range and confuse future operator inspection.
|
||||
persistState(state);
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.code !== 'ENOENT') {
|
||||
logger.warn('Failed to load audio levels store', err.message);
|
||||
@@ -103,18 +90,14 @@ function getAudioLevels() {
|
||||
hornGain: current.hornGain,
|
||||
ttsGain: current.ttsGain,
|
||||
forwardGain: current.forwardGain,
|
||||
userGainCaps: { ...current.userGainCaps },
|
||||
maxPersonalAdjustmentPercent: current.maxPersonalAdjustmentPercent,
|
||||
updatedAt: current.updatedAt,
|
||||
updatedBy: current.updatedBy,
|
||||
capsUpdatedAt: current.capsUpdatedAt,
|
||||
capsUpdatedBy: current.capsUpdatedBy,
|
||||
adjustmentRangeUpdatedAt: current.adjustmentRangeUpdatedAt,
|
||||
adjustmentRangeUpdatedBy: current.adjustmentRangeUpdatedBy,
|
||||
};
|
||||
}
|
||||
|
||||
function getUserGainCaps() {
|
||||
return { ...loadState().userGainCaps };
|
||||
}
|
||||
|
||||
function emitChange(reason = 'update', extra = {}) {
|
||||
audioLevelsEvents.emit('change', {
|
||||
reason,
|
||||
@@ -132,30 +115,19 @@ function getAdminLimits() {
|
||||
};
|
||||
}
|
||||
|
||||
function getGainCeilings(hasBoost) {
|
||||
const current = loadState();
|
||||
return resolveCeilings({
|
||||
adminLimits: getAdminLimits(),
|
||||
boostCaps: current.userGainCaps,
|
||||
hasBoost,
|
||||
});
|
||||
function canUsePersonalAdjustments(socket) {
|
||||
if (isAdmin(socket)) return true;
|
||||
const userId = getUserIdForSocket(socket);
|
||||
return Boolean(userId && hasUserPermission(userId, PERSONAL_ADJUSTMENT_PERMISSION));
|
||||
}
|
||||
|
||||
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 getAdjustmentsForSocket(socket) {
|
||||
if (!canUsePersonalAdjustments(socket)) return normalizeAdjustments({}, 0);
|
||||
return normalizeAdjustments(socket?.data?.audioAdjustments, loadState().maxPersonalAdjustmentPercent);
|
||||
}
|
||||
|
||||
function getEffectiveLevelsForSocket(socket) {
|
||||
return applyCeilings(getUserGainsForSocket(socket), getGainCeilingsForSocket(socket));
|
||||
return applyAdjustments(getAdminLimits(), getAdjustmentsForSocket(socket));
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -234,55 +206,49 @@ function setAudioLevels(input = {}, actor = null) {
|
||||
return getAudioLevels();
|
||||
}
|
||||
|
||||
function setUserGainCaps(input = {}, actor = null) {
|
||||
function setMaxPersonalAdjustmentPercent(value, actor = null) {
|
||||
const current = loadState();
|
||||
const next = {
|
||||
...current,
|
||||
userGainCaps: normalizeUserGainCaps(input, current.userGainCaps),
|
||||
capsUpdatedAt: Date.now(),
|
||||
capsUpdatedBy: actor,
|
||||
maxPersonalAdjustmentPercent: clampMaximumAdjustmentPercent(value, current.maxPersonalAdjustmentPercent),
|
||||
adjustmentRangeUpdatedAt: Date.now(),
|
||||
adjustmentRangeUpdatedBy: 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.
|
||||
A narrower range must take effect immediately for current drivers rather
|
||||
than leaving an out-of-range multiplier active until their next turn.
|
||||
*/
|
||||
pushLevelsToAllRovers();
|
||||
emitChange('user_caps_set');
|
||||
return getUserGainCaps();
|
||||
emitChange('personal_adjustment_range_set');
|
||||
return loadState().maxPersonalAdjustmentPercent;
|
||||
}
|
||||
|
||||
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);
|
||||
function setSocketAdjustments(socket, input = {}) {
|
||||
socket.data = socket.data || {};
|
||||
// Store only server-normalized percentages on the transport. The cookie is a
|
||||
// browser preference, while permission and range enforcement remain here.
|
||||
socket.data.audioAdjustments = normalizeAdjustments(input, 100);
|
||||
pushLevelsForSocket(socket);
|
||||
emitChange('user_gains_set', { scope: 'user', userId });
|
||||
return getAudioGainStateForSocket(socket);
|
||||
emitChange('personal_adjustments_set', { scope: 'socket', socketId: socket.id });
|
||||
return getAudioAdjustmentStateForSocket(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.
|
||||
The client receives the percentages the server accepted, the permitted range,
|
||||
and the resulting multipliers. This keeps the UI honest even when a cookie was
|
||||
edited or an administrator changed permission while the browser was online.
|
||||
*/
|
||||
function getAudioGainStateForSocket(socket) {
|
||||
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
|
||||
const values = getUserGainsForSocket(socket);
|
||||
const ceilings = getGainCeilings(hasBoost);
|
||||
function getAudioAdjustmentStateForSocket(socket) {
|
||||
const allowed = canUsePersonalAdjustments(socket);
|
||||
const maximum = loadState().maxPersonalAdjustmentPercent;
|
||||
const values = allowed ? getAdjustmentsForSocket(socket) : normalizeAdjustments({}, 0);
|
||||
return {
|
||||
values,
|
||||
ceilings,
|
||||
effective: applyCeilings(values, ceilings),
|
||||
boostGranted: hasBoost,
|
||||
adminLimits: getAdminLimits(),
|
||||
boostCaps: getUserGainCaps(),
|
||||
allowed,
|
||||
maxAdjustmentPercent: maximum,
|
||||
effective: applyAdjustments(getAdminLimits(), values),
|
||||
baseLevels: getAdminLimits(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -311,6 +277,23 @@ setImmediate(() => {
|
||||
}
|
||||
});
|
||||
|
||||
identityEvents.on('change', ({ reason, userId } = {}) => {
|
||||
if (!userId || !['permission_granted', 'permission_revoked', 'identify'].includes(reason)) return;
|
||||
io.sockets.sockets.forEach((socket) => {
|
||||
if (getUserIdForSocket(socket) !== userId) return;
|
||||
pushLevelsForSocket(socket);
|
||||
// Permission changes alter both effective rover output and the controls the
|
||||
// browser may use, so each affected connection receives a fresh session.
|
||||
emitChange('personal_adjustment_permission_changed', { scope: 'socket', socketId: socket.id });
|
||||
});
|
||||
});
|
||||
|
||||
roleEvents.on('change', ({ socket } = {}) => {
|
||||
// Administrators implicitly have this capability, so login/logout can change
|
||||
// the effective adjustment even though no database permission row changed.
|
||||
if (socket) pushLevelsForSocket(socket);
|
||||
});
|
||||
|
||||
io.on('connection', (socket) => {
|
||||
socket.on('audioLevels:get', (_, cb = () => {}) => {
|
||||
cb({ success: true, levels: getAudioLevels() });
|
||||
@@ -329,30 +312,30 @@ io.on('connection', (socket) => {
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:setUserCaps', (payload = {}, cb = () => {}) => {
|
||||
socket.on('audioLevels:setPersonalAdjustmentRange', (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 });
|
||||
const maxPersonalAdjustmentPercent = setMaxPersonalAdjustmentPercent(payload?.maxAdjustmentPercent, actor);
|
||||
cb({ success: true, maxPersonalAdjustmentPercent });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:getUserGains', (_, cb = () => {}) => {
|
||||
socket.on('audioLevels:getPersonalAdjustments', (_, cb = () => {}) => {
|
||||
try {
|
||||
cb({ success: true, audioGains: getAudioGainStateForSocket(socket) });
|
||||
cb({ success: true, audioAdjustments: getAudioAdjustmentStateForSocket(socket) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('audioLevels:setUserGains', (payload = {}, cb = () => {}) => {
|
||||
socket.on('audioLevels:setPersonalAdjustments', (payload = {}, cb = () => {}) => {
|
||||
try {
|
||||
cb({ success: true, audioGains: setUserGains(socket, payload || {}) });
|
||||
cb({ success: true, audioAdjustments: setSocketAdjustments(socket, payload || {}) });
|
||||
} catch (err) {
|
||||
cb({ error: err.message });
|
||||
}
|
||||
@@ -362,17 +345,15 @@ io.on('connection', (socket) => {
|
||||
loadState();
|
||||
|
||||
module.exports = {
|
||||
GAIN_KEYS,
|
||||
USER_GAIN_CAP_DEFAULTS,
|
||||
ADJUSTMENT_FIELDS,
|
||||
PERSONAL_ADJUSTMENT_PERMISSION,
|
||||
DEFAULT_MAX_PERSONAL_ADJUSTMENT_PERCENT,
|
||||
getAudioLevels,
|
||||
setAudioLevels,
|
||||
getUserGainCaps,
|
||||
setUserGainCaps,
|
||||
getUserGains,
|
||||
setUserGains,
|
||||
getGainCeilingsForSocket,
|
||||
setMaxPersonalAdjustmentPercent,
|
||||
setSocketAdjustments,
|
||||
getEffectiveLevelsForSocket,
|
||||
getAudioGainStateForSocket,
|
||||
getAudioAdjustmentStateForSocket,
|
||||
pushLevelsToRover,
|
||||
audioLevelsEvents,
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user