From f3b349bb4a5994e84d271262e9c15995f1b7073e Mon Sep 17 00:00:00 2001 From: Saul5662 Date: Wed, 29 Jul 2026 04:04:22 +0100 Subject: [PATCH] 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 --- .../src/services/audioLevelsService/index.js | 281 +++++++++++++++++- server/src/services/sessionService/index.js | 16 +- 2 files changed, 289 insertions(+), 8 deletions(-) diff --git a/server/src/services/audioLevelsService/index.js b/server/src/services/audioLevelsService/index.js index 3308b580..649f2493 100644 --- a/server/src/services/audioLevelsService/index.js +++ b/server/src/services/audioLevelsService/index.js @@ -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, }; 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(); });