feat(audio): per-user horn/TTS/forward volume with admin-capped ceilings

Every user now gets a personal 0-1 volume for horn, TTS, and mic forward.
The value is stored as a fraction of the ceiling that applies to them, so
lowering the global admin gain quiets everyone immediately instead of
leaving stale absolute values behind.

Ceilings resolve in three layers: the global admin gain is the default
ceiling; the audioGainBoost flag raises it to an admin-editable hard cap
(default 0.5x horn, 0.8x TTS, 0.4x forward); Math.max keeps the flag from
ever lowering a ceiling if the global gain is set higher than a cap.

Preferences live in identity feature state rather than a cookie so they
follow the user and cannot be raised client-side. The rover exposes gain
as three ALSA masters, so the resolved gains pushed to a rover are those
of the socket currently holding audio control -- re-pushed on driver
join/leave and every turn rotation.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Saul5662
2026-07-29 04:04:22 +01:00
co-authored by Claude
parent 81f52c29d8
commit f3b349bb4a
2 changed files with 289 additions and 8 deletions
+275 -6
View File
@@ -9,6 +9,7 @@ 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 audioLevelsEvents = new EventEmitter();
@@ -16,11 +17,32 @@ const DATA_DIR = resolveDataDir();
const STORE_PATH = resolveDataPath('audio-levels.json');
const config = loadConfig();
const configuredDefaults = config.audioLevels || {};
const configuredUserCaps = configuredDefaults.userGainCaps || {};
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
/*
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'];
/*
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,
};
function clampGain(value, fallback = 1) {
@@ -29,13 +51,41 @@ function clampGain(value, fallback = 1) {
return Math.max(0, Math.min(4, num));
}
function clampFraction(value, fallback = 1) {
const num = Number(value);
if (!Number.isFinite(num)) return fallback;
return Math.max(0, Math.min(1, num));
}
const DEFAULTS = {
hornGain: clampGain(configuredDefaults.hornGain, 1),
ttsGain: clampGain(configuredDefaults.ttsGain, 1),
forwardGain: clampGain(configuredDefaults.forwardGain, 1),
userGainCaps: {
hornGain: clampGain(configuredUserCaps.hornGain, USER_GAIN_CAP_DEFAULTS.hornGain),
ttsGain: clampGain(configuredUserCaps.ttsGain, USER_GAIN_CAP_DEFAULTS.ttsGain),
forwardGain: clampGain(configuredUserCaps.forwardGain, USER_GAIN_CAP_DEFAULTS.forwardGain),
},
};
function normalizeUserGainCaps(raw = {}, fallback = DEFAULTS.userGainCaps) {
return {
hornGain: clampGain(raw?.hornGain, fallback.hornGain),
ttsGain: clampGain(raw?.ttsGain, fallback.ttsGain),
forwardGain: clampGain(raw?.forwardGain, fallback.forwardGain),
};
}
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),
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 +121,118 @@ 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,
});
}
/*
Ceiling resolution. 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 simply keeps the global
ceiling instead of losing volume for holding a permission.
*/
function getGainCeilings(hasBoost) {
const current = loadState();
const caps = current.userGainCaps;
const ceilings = {};
GAIN_KEYS.forEach((key) => {
const adminCeiling = clampGain(current[key], 0);
ceilings[key] = hasBoost ? Math.max(adminCeiling, clampGain(caps[key], 0)) : adminCeiling;
});
return ceilings;
}
function getGainCeilingsForSocket(socket) {
return getGainCeilings(Boolean(socket?.data?.hasAudioGainBoost));
}
function normalizeUserGains(raw = {}) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampFraction(raw?.[key], 1);
});
return out;
}
function getUserGains(userId) {
if (!userId) return normalizeUserGains({});
return normalizeUserGains(getFeatureState(userId, USER_GAINS_NAMESPACE, {}));
}
function getUserGainsForSocket(socket) {
return getUserGains(getUserIdForSocket(socket));
}
function applyCeilings(fractions, ceilings) {
const out = {};
GAIN_KEYS.forEach((key) => {
out[key] = clampGain(clampFraction(fractions?.[key], 1) * clampGain(ceilings?.[key], 0), 0);
});
return out;
}
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);
if (!owner) {
const current = loadState();
return {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
};
}
return getEffectiveLevelsForSocket(owner);
}
function pushLevelsToRover(roverId) {
if (!roverId) return;
const record = roverManager.rovers.get(roverId);
@@ -90,7 +240,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 +255,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 +276,88 @@ 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 current = loadState();
const hasBoost = Boolean(socket?.data?.hasAudioGainBoost);
const values = getUserGainsForSocket(socket);
const ceilings = getGainCeilings(hasBoost);
return {
values,
ceilings,
effective: applyCeilings(values, ceilings),
boostGranted: hasBoost,
adminLimits: {
hornGain: current.hornGain,
ttsGain: current.ttsGain,
forwardGain: current.forwardGain,
},
boostCaps: { ...current.userGainCaps },
};
}
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 +375,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,
};
+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();
});