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
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+1 -1
View File
@@ -11,7 +11,7 @@
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title>
<script type="module" crossorigin src="/assets/index-RGOkx1Qn.js"></script>
<script type="module" crossorigin src="/assets/index-CyMJCyW9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
</head>
<body>
+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 />;
}