moving audio gain perms around

This commit is contained in:
legop3
2026-08-07 22:34:04 -04:00
parent 9702cf0f82
commit 6c69c583c5
34 changed files with 936 additions and 1132 deletions
@@ -17,10 +17,8 @@ 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.
*/
// These are the physical rover gain channels. Personal settings remain signed
// percentages and never replace these server-owned base multipliers.
const GAIN_FIELDS = [
{ key: 'hornGain', label: 'Horn gain' },
{ key: 'ttsGain', label: 'TTS gain' },
@@ -109,7 +107,7 @@ export default function AdminPanelContent() {
updateAllRovers,
rebootServer,
setAudioLevels,
setUserAudioGainCaps,
setPersonalAudioAdjustmentRange,
setPrivateSafety,
llmControl,
overseerControl,
@@ -132,12 +130,11 @@ export default function AdminPanelContent() {
const reasonUpdatedAt = session?.adminReason?.updatedAt || null;
const [reasonDraft, setReasonDraft] = useState(currentReason);
const currentAudioLevels = session?.audioLevels || {};
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 [maxAdjustmentDraft, setMaxAdjustmentDraft] = useState(
() => Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0,
);
const [privateSafetyDrafts, setPrivateSafetyDrafts] = useState({});
const [privateSafetyDirty, setPrivateSafetyDirty] = useState({});
@@ -317,14 +314,9 @@ 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 () => {
const handlePersonalAdjustmentRangeSave = async () => {
try {
await setUserAudioGainCaps(userGainCapDraft);
await setPersonalAudioAdjustmentRange(maxAdjustmentDraft);
} catch (err) {
alert(err.message);
}
@@ -356,9 +348,8 @@ export default function AdminPanelContent() {
}, [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]);
setMaxAdjustmentDraft(Number(currentAudioLevels.maxPersonalAdjustmentPercent) || 0);
}, [currentAudioLevels.maxPersonalAdjustmentPercent]);
useEffect(() => {
@@ -462,7 +453,7 @@ export default function AdminPanelContent() {
/>
))}
<p className="text-xs text-slate-500">
These are the volume ceilings for ordinary users. Each user picks a 0-100% share of them.
These are the base multipliers. Approved personal adjustments are calculated around these values.
</p>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleAudioLevelsSave} className="button-dark">
@@ -472,26 +463,32 @@ export default function AdminPanelContent() {
</div>
<div className="space-y-0.5">
<div className="flex items-center justify-between text-xs text-slate-400">
<span>VIP gain boost hard caps</span>
{session?.audioLevels?.capsUpdatedAt ? (
<span>Updated {new Date(session.audioLevels.capsUpdatedAt).toLocaleString()}</span>
<span>Maximum personal adjustment</span>
{session?.audioLevels?.adjustmentRangeUpdatedAt ? (
<span>Updated {new Date(session.audioLevels.adjustmentRangeUpdatedAt).toLocaleString()}</span>
) : null}
</div>
{GAIN_FIELDS.map(({ key, label }) => (
<GainSlider
key={key}
label={label}
value={userGainCapDraft[key]}
onChange={handleUserGainCapDraft(key)}
<label className="grid gap-0.5 text-xs text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Allowed range in both directions</span>
<span>±{Math.round(Number(maxAdjustmentDraft) || 0)}%</span>
</div>
<input
type="range"
min="0"
max="100"
step="1"
value={maxAdjustmentDraft}
onChange={(event) => setMaxAdjustmentDraft(Number(event.target.value) || 0)}
className="w-full accent-emerald-500"
/>
))}
</label>
<p className="text-xs text-slate-500">
Ceilings for VIPs granted the boost with <code>rs gain grant &lt;vip&gt;</code>. A boost never lowers
someone&apos;s limit, so a cap below the global gain above has no effect.
Users with the personal audio adjustment permission can reduce or increase each base level by this percentage.
</p>
<div className="flex gap-0.5 text-xs">
<button type="button" onClick={handleUserGainCapsSave} className="button-dark">
Apply boost caps
<button type="button" onClick={handlePersonalAdjustmentRangeSave} className="button-dark">
Apply adjustment range
</button>
</div>
</div>
+85 -105
View File
@@ -1,134 +1,114 @@
// 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.
// Personal Volume Adjustment Card
// Purpose: Lets an approved user offset horn, text-to-speech, and microphone output around server-owned base levels.
// Scope: Persists signed percentages in roverSettings while the server owns permission, clamping, and gain conversion.
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import CardFrame from '../CardFrame/index.jsx';
import { useSession } from '../../context/SessionContext.jsx';
import { useSettingsNamespace } from '../../settings/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' },
const ADJUSTMENT_FIELDS = [
{ key: 'hornPercent', label: 'Horn volume' },
{ key: 'ttsPercent', label: 'Text-to-speech volume' },
{ key: 'forwardPercent', label: 'Microphone volume' },
];
// Dragging a range input fires continuously; persist once the user settles.
const DEFAULT_ADJUSTMENTS = { hornPercent: 0, ttsPercent: 0, forwardPercent: 0 };
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 clampPercent(value, maximum) {
const number = Number(value);
const limit = Math.max(0, Number(maximum) || 0);
if (!Number.isFinite(number)) return 0;
return Math.round(Math.max(-limit, Math.min(limit, number)));
}
function normalizeValues(raw) {
const out = {};
GAIN_FIELDS.forEach(({ key }) => {
out[key] = clampFraction(raw?.[key], 1);
function normalizeAdjustments(raw, maximum) {
const normalized = {};
ADJUSTMENT_FIELDS.forEach(({ key }) => {
normalized[key] = clampPercent(raw?.[key], maximum);
});
return out;
return normalized;
}
function formatGain(value) {
const num = Number(value);
return `${Number.isFinite(num) ? num.toFixed(2) : '0.00'}x`;
function formatPercent(value) {
const number = Number(value) || 0;
return `${number > 0 ? '+' : ''}${number}%`;
}
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],
const { session, setPersonalAudioAdjustments } = useSession();
const { value: savedAdjustments, save: saveAdjustments } = useSettingsNamespace(
'audioAdjustments',
DEFAULT_ADJUSTMENTS,
);
const serverState = session?.audioAdjustments || null;
const allowed = Boolean(serverState?.allowed);
const maximum = Math.max(0, Number(serverState?.maxAdjustmentPercent) || 0);
const normalizedSaved = useMemo(
() => normalizeAdjustments(savedAdjustments, maximum),
[maximum, savedAdjustments],
);
const [draft, setDraft] = useState(normalizedSaved);
const [error, setError] = useState(null);
const timerRef = useRef(null);
useEffect(() => {
setDraft(normalizedSaved);
}, [normalizedSaved]);
const commit = useCallback(async (next) => {
const normalized = normalizeAdjustments(next, maximum);
// Saving first makes the cookie the durable source used by every reconnect
// and session:identify update. The socket call applies it immediately to a
// rover the current browser may already control.
saveAdjustments(normalized);
try {
await setPersonalAudioAdjustments(normalized);
setError(null);
} catch (err) {
setError(err?.message || 'Failed to apply volume adjustments');
}
}, [maximum, saveAdjustments, setPersonalAudioAdjustments]);
useEffect(() => () => {
if (commitTimerRef.current) clearTimeout(commitTimerRef.current);
if (timerRef.current) clearTimeout(timerRef.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);
const next = { ...draft, [key]: clampPercent(event.target.value, maximum) };
setDraft(next);
if (timerRef.current) clearTimeout(timerRef.current);
timerRef.current = setTimeout(() => commit(next), COMMIT_DEBOUNCE_MS);
};
// Sliders would be misleading before the first session sync lands.
if (!audioGains) return null;
if (!serverState) return null;
return (
<CardFrame title="Volume" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
{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 (
<label
key={key}
className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-sm text-white"
>
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{Math.round(fraction * 100)}% · {formatGain(fraction * ceiling)}
</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={fraction}
onChange={handleChange(key)}
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
disabled={muted}
/>
<span className="text-xs text-slate-400">
{muted ? 'Muted by admin gain settings.' : `100% = ${formatGain(ceiling)} (your current limit)`}
<CardFrame title="Personal volume adjustment" className="lg:col-span-2" bodyClassName="space-y-1 p-1 text-sm">
{ADJUSTMENT_FIELDS.map(({ key, label }) => (
<label key={key} className="mx-auto block w-full max-w-lg rounded bg-neutral-800/80 px-1.5 py-1 text-white">
<div className="grid grid-cols-[minmax(0,1fr)_auto] items-center gap-1.5">
<span className="min-w-0 font-semibold text-white">{label}</span>
<span className="rounded bg-neutral-900 px-1 py-0.5 text-xs text-white">
{formatPercent(draft[key])}
</span>
</label>
);
})}
<p className="mx-auto w-full max-w-lg text-xs leading-snug text-white">
{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.'}
</p>
</div>
<input
type="range"
min={-maximum}
max={maximum}
step="1"
value={clampPercent(draft[key], maximum)}
onChange={handleChange(key)}
className="mt-1 w-full accent-emerald-500 disabled:opacity-50"
disabled={!allowed || maximum <= 0}
/>
<span className="text-xs text-slate-400">
{allowed
? `Allowed range: -${maximum}% to +${maximum}%. Center is no adjustment.`
: 'An administrator must approve personal volume adjustments for your user.'}
</span>
</label>
))}
{error && <p className="mx-auto w-full max-w-lg text-xs text-rose-300">{error}</p>}
</CardFrame>
);