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
+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();
});