volume stuff

This commit is contained in:
legop3
2026-02-18 21:39:34 -05:00
parent 7e772ab607
commit 438fff0902
12 changed files with 428 additions and 160 deletions
+1 -4
View File
@@ -7,7 +7,6 @@ import MobileControls, {
MobileRightColumn,
} from './components/MobileControls.jsx';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
import { SettingsProvider } from './settings/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx';
@@ -173,9 +172,7 @@ function App() {
return (
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<SettingsProvider>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</SettingsProvider>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</div>
);
}
+98
View File
@@ -11,6 +11,7 @@ import SocketLogPanel from './SocketLogPanel.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const manualTabs = [
{ key: 'start', label: 'Start OI' },
@@ -32,7 +33,16 @@ export default function SettingsPanel() {
hudMapDesktop: false,
connectionTransport: 'websocket',
});
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const connectionTransport = pageSettings?.connectionTransport || 'websocket';
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const roverVolume = Number.isFinite(audioSettings?.roverVolume) ? audioSettings.roverVolume : AUDIO_SETTINGS_DEFAULTS.roverVolume;
const autoLevelEnabled =
typeof audioSettings?.autoLevelEnabled === 'boolean'
? audioSettings.autoLevelEnabled
: AUDIO_SETTINGS_DEFAULTS.autoLevelEnabled;
const autoLevelMode = audioSettings?.autoLevelMode === 'duck' ? 'duck' : 'compressor';
const sensorButtons = useMemo(
() => [
@@ -55,6 +65,22 @@ export default function SettingsPanel() {
socket.disconnect();
socket.connect();
};
const handleAudioRange = (key) => (event) => {
const raw = Number(event.target.value);
const next = Number.isFinite(raw) ? Math.max(0, Math.min(1, raw)) : 0;
saveAudioSettings((current) => ({ ...(current ?? {}), [key]: next }));
};
const handleAutoLevelEnabled = (event) => {
const checked = Boolean(event.target.checked);
saveAudioSettings((current) => ({ ...(current ?? {}), autoLevelEnabled: checked }));
};
const handleAutoLevelMode = (event) => {
const next = event.target.value === 'duck' ? 'duck' : 'compressor';
saveAudioSettings((current) => ({ ...(current ?? {}), autoLevelMode: next }));
};
return (
<Tabs defaultTab="keybindings">
<TabList>
@@ -89,6 +115,78 @@ export default function SettingsPanel() {
</label>
<p className="text-xs text-slate-500">Mobile HUD keeps the map on by default.</p>
</section>
<section className="panel-section space-y-0.5 text-sm">
<p className="text-slate-400">Audio</p>
<label className="grid gap-0.5 text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Master volume</span>
<span className="text-xs text-slate-400">{Math.round(masterVolume * 100)}%</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={masterVolume}
onChange={handleAudioRange('masterVolume')}
className="w-full accent-emerald-500"
/>
</label>
<label className="grid gap-0.5 text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Alert/page sounds</span>
<span className="text-xs text-slate-400">{Math.round(alertVolume * 100)}%</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={alertVolume}
onChange={handleAudioRange('alertVolume')}
className="w-full accent-emerald-500"
/>
</label>
<label className="grid gap-0.5 text-slate-200">
<div className="flex items-center justify-between gap-0.5">
<span>Rover audio</span>
<span className="text-xs text-slate-400">{Math.round(roverVolume * 100)}%</span>
</div>
<input
type="range"
min="0"
max="1"
step="0.01"
value={roverVolume}
onChange={handleAudioRange('roverVolume')}
className="w-full accent-emerald-500"
/>
</label>
<label className="flex items-center justify-between gap-0.5 text-slate-200">
<span>Auto-level rover audio</span>
<input
type="checkbox"
className="accent-emerald-500"
checked={autoLevelEnabled}
onChange={handleAutoLevelEnabled}
/>
</label>
<label className="flex items-center justify-between gap-0.5 text-slate-200">
<span>Auto-level mode</span>
<select
value={autoLevelMode}
onChange={handleAutoLevelMode}
className="field-input text-sm"
disabled={!autoLevelEnabled}
>
<option value="compressor">Compressor (preferred)</option>
<option value="duck">Brush/vacuum ducking</option>
</select>
</label>
<p className="text-xs text-slate-500">
Compressor evens out incoming audio; ducking lowers volume when cleaning motors are active.
</p>
</section>
<section className="panel-section space-y-0.5 text-sm">
<p className="text-slate-400">Connection</p>
<label className="flex items-center justify-between gap-0.5 text-slate-200">
+11 -3
View File
@@ -1,15 +1,19 @@
import { useEffect, useMemo, useRef } from 'react';
import turnSound from '../assets/turn_alert.mp3';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
function useAudio(src) {
function useAudio(src, volume = 1) {
const audioRef = useRef(null);
useEffect(() => {
audioRef.current = new Audio(src);
audioRef.current.volume = volume;
audioRef.current.load();
}, [src]);
}, [src, volume]);
const play = () => {
if (!audioRef.current) return;
audioRef.current.volume = volume;
audioRef.current.currentTime = 0;
audioRef.current.play().catch(() => {});
};
@@ -18,7 +22,11 @@ function useAudio(src) {
export default function TurnAlertListener() {
const { session, pushAlert } = useSession();
const playSound = useAudio(turnSound);
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
const playSound = useAudio(turnSound, effectiveAlertVolume);
const seenRoversRef = useRef(new Set());
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]);
+157 -3
View File
@@ -5,6 +5,7 @@ import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import SocialButton from './SocialButton.jsx';
import BatteryBar from './BatteryBar.jsx';
import { buildBatteryVisual } from '../lib/battery.js';
@@ -12,6 +13,9 @@ import { buildBatteryVisual } from '../lib/battery.js';
const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000;
const AUDIO_RETRY_MS = 3000;
const AUDIO_DUCK_FACTOR = 0.55;
const BRUSH_CURRENT_THRESHOLD_MA = 40;
const COMPRESSOR_REDUCTION_ACTIVE_DB = -0.75;
export default function VideoTile({
sessionInfo,
@@ -44,15 +48,34 @@ export default function VideoTile({
const audioRestartTimer = useRef(null);
const audioPlayInterval = useRef(null);
const unmuteTimer = useRef(null);
const audioContextRef = useRef(null);
const audioSourceNodeRef = useRef(null);
const audioCompressorRef = useRef(null);
const audioGainRef = useRef(null);
const reductionPollRef = useRef(null);
const graphFailedRef = useRef(false);
const [status, setStatus] = useState('idle');
const [detail, setDetail] = useState(null);
const [audioStatus, setAudioStatus] = useState('idle');
const [audioDetail, setAudioDetail] = useState(null);
const [levelIndicator, setLevelIndicator] = useState(null);
const [restartToken, setRestartToken] = useState(0);
const [audioRestartToken, setAudioRestartToken] = useState(0);
const [muted, setMuted] = useState(true);
const usingSnapshot = videoMode === 'snapshot';
const sensors = telemetryFrame?.sensors;
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const masterVolume = Number.isFinite(audioSettings?.masterVolume)
? audioSettings.masterVolume
: AUDIO_SETTINGS_DEFAULTS.masterVolume;
const roverVolume = Number.isFinite(audioSettings?.roverVolume)
? audioSettings.roverVolume
: AUDIO_SETTINGS_DEFAULTS.roverVolume;
const autoLevelEnabled = typeof audioSettings?.autoLevelEnabled === 'boolean'
? audioSettings.autoLevelEnabled
: AUDIO_SETTINGS_DEFAULTS.autoLevelEnabled;
const autoLevelMode = audioSettings?.autoLevelMode === 'duck' ? 'duck' : 'compressor';
const baseRoverGain = Math.max(0, Math.min(1, masterVolume * roverVolume));
const batteryCharge = sensors?.batteryChargeMah ?? null;
const desktopLayout = layoutFormat === 'desktop';
const mobileHud = !desktopLayout;
@@ -87,6 +110,14 @@ export default function VideoTile({
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
const overlayVisible = Boolean(overlayMotors.length);
const brushOrVacuumActive = Boolean(
(Number(sensors?.mainBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
(Number(sensors?.sideBrushCurrentMa) || 0) > BRUSH_CURRENT_THRESHOLD_MA ||
sensors?.wheelOvercurrents?.mainBrush ||
sensors?.wheelOvercurrents?.sideBrush,
);
const duckGain = autoLevelEnabled && autoLevelMode === 'duck' && brushOrVacuumActive ? AUDIO_DUCK_FACTOR : 1;
const effectiveRoverGain = Math.max(0, Math.min(1, baseRoverGain * duckGain));
const discordUrl =
session?.socials?.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase();
@@ -116,6 +147,50 @@ export default function VideoTile({
clearTimeout(audioRestartTimer.current);
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS);
}, []);
const ensureAudioGraph = useCallback(() => {
if (graphFailedRef.current) return false;
const audioEl = audioRef.current;
if (!audioEl || typeof window === 'undefined' || typeof window.AudioContext !== 'function') {
return false;
}
try {
if (!audioContextRef.current) {
audioContextRef.current = new window.AudioContext();
}
const ctx = audioContextRef.current;
if (!audioSourceNodeRef.current) {
audioSourceNodeRef.current = ctx.createMediaElementSource(audioEl);
}
if (!audioCompressorRef.current) {
const compressor = ctx.createDynamicsCompressor();
compressor.threshold.value = -25;
compressor.knee.value = 20;
compressor.ratio.value = 5;
compressor.attack.value = 0.003;
compressor.release.value = 0.25;
audioCompressorRef.current = compressor;
}
if (!audioGainRef.current) {
audioGainRef.current = ctx.createGain();
}
audioSourceNodeRef.current.disconnect();
audioCompressorRef.current.disconnect();
audioGainRef.current.disconnect();
audioSourceNodeRef.current.connect(audioCompressorRef.current);
audioCompressorRef.current.connect(audioGainRef.current);
audioGainRef.current.connect(ctx.destination);
return true;
} catch {
graphFailedRef.current = true;
return false;
}
}, []);
const resumeAudioContext = useCallback(async () => {
const ctx = audioContextRef.current;
if (!ctx || ctx.state !== 'suspended') return;
await ctx.resume().catch(() => {});
}, []);
const ensurePlayback = useCallback(async () => {
const video = videoRef.current;
@@ -168,6 +243,13 @@ export default function VideoTile({
clearTimeout(audioRestartTimer.current);
clearTimeout(unmuteTimer.current);
clearInterval(audioPlayInterval.current);
clearInterval(reductionPollRef.current);
audioSourceNodeRef.current?.disconnect?.();
audioCompressorRef.current?.disconnect?.();
audioGainRef.current?.disconnect?.();
if (audioContextRef.current?.state && audioContextRef.current.state !== 'closed') {
audioContextRef.current.close().catch(() => {});
}
},
[],
);
@@ -231,6 +313,72 @@ export default function VideoTile({
}
}, [status, sessionInfo?.url, scheduleRestart]);
useEffect(() => {
const audioEl = audioRef.current;
clearInterval(reductionPollRef.current);
reductionPollRef.current = null;
if (!audioEl || !audioSessionInfo?.url) {
setLevelIndicator(null);
return;
}
const compressorMode = autoLevelEnabled && autoLevelMode === 'compressor';
const duckMode = autoLevelEnabled && autoLevelMode === 'duck';
const graphReady = compressorMode && ensureAudioGraph();
if (graphReady) {
const compressor = audioCompressorRef.current;
const gainNode = audioGainRef.current;
audioEl.volume = 1;
if (gainNode) {
gainNode.gain.value = effectiveRoverGain;
}
if (compressor) {
compressor.threshold.value = -25;
compressor.knee.value = 20;
compressor.ratio.value = 5;
compressor.attack.value = 0.003;
compressor.release.value = 0.25;
}
resumeAudioContext();
reductionPollRef.current = setInterval(() => {
const reduction = compressor?.reduction;
if (typeof reduction === 'number' && reduction <= COMPRESSOR_REDUCTION_ACTIVE_DB) {
setLevelIndicator(`Level: ${Math.round(Math.abs(reduction))}dB`);
} else {
setLevelIndicator(null);
}
}, 180);
return;
}
const hasGraph = Boolean(audioSourceNodeRef.current && audioGainRef.current);
audioEl.volume = hasGraph ? 1 : effectiveRoverGain;
if (audioCompressorRef.current) {
audioCompressorRef.current.threshold.value = 0;
audioCompressorRef.current.knee.value = 0;
audioCompressorRef.current.ratio.value = 1;
audioCompressorRef.current.attack.value = 0.003;
audioCompressorRef.current.release.value = 0.25;
}
if (audioGainRef.current) {
audioGainRef.current.gain.value = effectiveRoverGain;
}
if (duckMode && brushOrVacuumActive) {
setLevelIndicator('Level: ducking');
} else {
setLevelIndicator(null);
}
}, [
audioSessionInfo?.url,
autoLevelEnabled,
autoLevelMode,
brushOrVacuumActive,
effectiveRoverGain,
ensureAudioGraph,
resumeAudioContext,
]);
// Audio-only WHEP (no pausing/muting; keeps trying to play)
useEffect(() => {
if (!audioSessionInfo?.url || !audioRef.current) {
@@ -251,6 +399,7 @@ export default function VideoTile({
return nextStatus;
});
if (nextStatus === 'playing') {
resumeAudioContext();
audioRef.current?.play().catch(() => {});
}
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
@@ -277,7 +426,7 @@ export default function VideoTile({
active = false;
player?.stop();
};
}, [audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart]);
}, [audioSessionInfo?.url, audioSessionInfo?.token, audioRestartToken, scheduleAudioRestart, resumeAudioContext]);
// Keep nudging the audio element to play in case autoplay was blocked.
useEffect(() => {
@@ -292,10 +441,11 @@ export default function VideoTile({
return undefined;
}
const attemptPlay = () => {
const attemptPlay = async () => {
const target = audioRef.current;
if (!target) return;
if (!target.paused && !target.ended) return;
await resumeAudioContext();
target
.play()
.then(() => {
@@ -311,7 +461,7 @@ export default function VideoTile({
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
return () => clearInterval(audioPlayInterval.current);
}, [audioSessionInfo?.url, audioStatus]);
}, [audioSessionInfo?.url, audioStatus, resumeAudioContext]);
// Reflect audio element events back into status/detail so the HUD stays accurate.
useEffect(() => {
@@ -423,6 +573,7 @@ export default function VideoTile({
label={label}
status={renderedStatus}
audioStatus={renderedAudioStatus}
levelStatus={levelIndicator}
desktopLayout={desktopLayout}
layoutFormat={layoutFormat}
variant={hudVariant}
@@ -537,6 +688,7 @@ function HudOverlay({
label,
status,
audioStatus,
levelStatus,
desktopLayout = true,
layoutFormat = 'desktop',
variant = 'default',
@@ -626,6 +778,7 @@ function HudOverlay({
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div>
</div>
<div
@@ -682,6 +835,7 @@ function HudOverlay({
<div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div>
</div>
{turnTimerText ? (
+10 -2
View File
@@ -4,6 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { useSocket } from './SocketContext.jsx';
import { useSession } from './SessionContext.jsx';
import messageSound from '../assets/message.mp3';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const ChatContext = createContext({
messages: [],
@@ -21,6 +23,7 @@ const ChatContext = createContext({
export function ChatProvider({ children }) {
const socket = useSocket();
const { session, pushAlert } = useSession();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false);
@@ -30,6 +33,9 @@ export function ChatProvider({ children }) {
const typingRef = useRef(new Map());
const typingAlertRef = useRef(new Map());
const typingStateRef = useRef({ isTyping: false, lastSent: 0 });
const masterVolume = Number.isFinite(audioSettings?.masterVolume) ? audioSettings.masterVolume : AUDIO_SETTINGS_DEFAULTS.masterVolume;
const alertVolume = Number.isFinite(audioSettings?.alertVolume) ? audioSettings.alertVolume : AUDIO_SETTINGS_DEFAULTS.alertVolume;
const effectiveAlertVolume = Math.max(0, Math.min(1, masterVolume * alertVolume));
const rebuildTyping = useCallback(() => {
const entries = Array.from(typingRef.current.values())
@@ -49,15 +55,17 @@ export function ChatProvider({ children }) {
useEffect(() => {
audioRef.current = new Audio(messageSound);
audioRef.current.volume = effectiveAlertVolume;
audioRef.current.load();
}, []);
}, [effectiveAlertVolume]);
const playSound = useCallback(() => {
const audio = audioRef.current;
if (!audio) return;
audio.volume = effectiveAlertVolume;
audio.currentTime = 0;
audio.play().catch(() => {});
}, []);
}, [effectiveAlertVolume]);
useEffect(() => {
function handleMessage(payload = {}) {
+12 -9
View File
@@ -9,21 +9,24 @@ import { TelemetryProvider } from './context/TelemetryContext.jsx'
import { ChatProvider } from './context/ChatContext.jsx'
import SpectatorApp from './spectate/SpectatorApp.jsx'
import MiniSummaryApp from './mini/MiniSummaryApp.jsx'
import { SettingsProvider } from './settings/index.js'
createRoot(document.getElementById('root')).render(
<StrictMode>
<SocketProvider>
<SessionProvider>
<TelemetryProvider>
<ChatProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
</Routes>
</BrowserRouter>
</ChatProvider>
<SettingsProvider>
<ChatProvider>
<BrowserRouter>
<Routes>
<Route path="/" element={<App />} />
<Route path="/spectate" element={<SpectatorApp />} />
<Route path="/mini" element={<MiniSummaryApp />} />
</Routes>
</BrowserRouter>
</ChatProvider>
</SettingsProvider>
</TelemetryProvider>
</SessionProvider>
</SocketProvider>
+4 -7
View File
@@ -1,5 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { SettingsProvider } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js';
@@ -410,12 +409,10 @@ export default function MiniSummaryApp() {
}, []);
return (
<SettingsProvider>
<>
<MiniSummaryContent />
<AlertFeed scale={3} />
</>
</SettingsProvider>
<>
<MiniSummaryContent />
<AlertFeed scale={3} />
</>
);
}
+8
View File
@@ -90,3 +90,11 @@ export const HORN_SETTINGS_DEFAULTS = {
waveform: 'saw',
freqs: [440, 550, 660, 0],
};
export const AUDIO_SETTINGS_DEFAULTS = {
masterVolume: 1,
alertVolume: 0.3,
roverVolume: 1,
autoLevelEnabled: true,
autoLevelMode: 'compressor',
};
+1 -6
View File
@@ -1,5 +1,4 @@
import { useEffect, useState } from 'react';
import { SettingsProvider } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx';
import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
@@ -222,9 +221,5 @@ function SpectatorContent() {
}
export default function SpectatorApp() {
return (
<SettingsProvider>
<SpectatorContent />
</SettingsProvider>
);
return <SpectatorContent />;
}