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-status-bar-style" content="black-translucent" />
<meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" /> <meta name="apple-mobile-web-app-title" content="Multi Roomba Rover" />
<title>Multi Roomba Rover</title> <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"> <link rel="stylesheet" crossorigin href="/assets/index-Bz1ixYh7.css">
</head> </head>
<body> <body>
-3
View File
@@ -7,7 +7,6 @@ import MobileControls, {
MobileRightColumn, MobileRightColumn,
} from './components/MobileControls.jsx'; } from './components/MobileControls.jsx';
import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js'; import { ControlSystemProvider, KeyboardInputManager, GamepadInputManager } from './controls/index.js';
import { SettingsProvider } from './settings/index.js';
import RoomCameraPanel from './components/RoomCameraPanel.jsx'; import RoomCameraPanel from './components/RoomCameraPanel.jsx';
import LogPanel from './components/LogPanel.jsx'; import LogPanel from './components/LogPanel.jsx';
import DriverVideoPanel from './components/DriverVideoPanel.jsx'; import DriverVideoPanel from './components/DriverVideoPanel.jsx';
@@ -173,9 +172,7 @@ function App() {
return ( return (
<div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}> <div className={`bg-black text-slate-100 ${isDesktop ? 'h-screen overflow-hidden' : 'min-h-screen'}`}>
<SettingsProvider>
<AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} /> <AppWithProviders layout={layout} isDesktop={isDesktop} fullscreen={fullscreen} />
</SettingsProvider>
</div> </div>
); );
} }
+98
View File
@@ -11,6 +11,7 @@ import SocketLogPanel from './SocketLogPanel.jsx';
import { useHudMapSetting } from '../hooks/useHudMapSetting.js'; import { useHudMapSetting } from '../hooks/useHudMapSetting.js';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { useSocket } from '../context/SocketContext.jsx'; import { useSocket } from '../context/SocketContext.jsx';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const manualTabs = [ const manualTabs = [
{ key: 'start', label: 'Start OI' }, { key: 'start', label: 'Start OI' },
@@ -32,7 +33,16 @@ export default function SettingsPanel() {
hudMapDesktop: false, hudMapDesktop: false,
connectionTransport: 'websocket', connectionTransport: 'websocket',
}); });
const { value: audioSettings, save: saveAudioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const connectionTransport = pageSettings?.connectionTransport || 'websocket'; 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( const sensorButtons = useMemo(
() => [ () => [
@@ -55,6 +65,22 @@ export default function SettingsPanel() {
socket.disconnect(); socket.disconnect();
socket.connect(); 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 ( return (
<Tabs defaultTab="keybindings"> <Tabs defaultTab="keybindings">
<TabList> <TabList>
@@ -89,6 +115,78 @@ export default function SettingsPanel() {
</label> </label>
<p className="text-xs text-slate-500">Mobile HUD keeps the map on by default.</p> <p className="text-xs text-slate-500">Mobile HUD keeps the map on by default.</p>
</section> </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"> <section className="panel-section space-y-0.5 text-sm">
<p className="text-slate-400">Connection</p> <p className="text-slate-400">Connection</p>
<label className="flex items-center justify-between gap-0.5 text-slate-200"> <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 { useEffect, useMemo, useRef } from 'react';
import turnSound from '../assets/turn_alert.mp3'; import turnSound from '../assets/turn_alert.mp3';
import { useSession } from '../context/SessionContext.jsx'; 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); const audioRef = useRef(null);
useEffect(() => { useEffect(() => {
audioRef.current = new Audio(src); audioRef.current = new Audio(src);
audioRef.current.volume = volume;
audioRef.current.load(); audioRef.current.load();
}, [src]); }, [src, volume]);
const play = () => { const play = () => {
if (!audioRef.current) return; if (!audioRef.current) return;
audioRef.current.volume = volume;
audioRef.current.currentTime = 0; audioRef.current.currentTime = 0;
audioRef.current.play().catch(() => {}); audioRef.current.play().catch(() => {});
}; };
@@ -18,7 +22,11 @@ function useAudio(src) {
export default function TurnAlertListener() { export default function TurnAlertListener() {
const { session, pushAlert } = useSession(); 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 seenRoversRef = useRef(new Set());
const assignments = useMemo(() => session?.turnQueues || {}, [session?.turnQueues]); 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 { useChat } from '../context/ChatContext.jsx';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSettingsNamespace } from '../settings/index.js'; import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
import SocialButton from './SocialButton.jsx'; import SocialButton from './SocialButton.jsx';
import BatteryBar from './BatteryBar.jsx'; import BatteryBar from './BatteryBar.jsx';
import { buildBatteryVisual } from '../lib/battery.js'; import { buildBatteryVisual } from '../lib/battery.js';
@@ -12,6 +13,9 @@ import { buildBatteryVisual } from '../lib/battery.js';
const RESTART_DELAY_MS = 2000; const RESTART_DELAY_MS = 2000;
const UNMUTE_RETRY_MS = 3000; const UNMUTE_RETRY_MS = 3000;
const AUDIO_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({ export default function VideoTile({
sessionInfo, sessionInfo,
@@ -44,15 +48,34 @@ export default function VideoTile({
const audioRestartTimer = useRef(null); const audioRestartTimer = useRef(null);
const audioPlayInterval = useRef(null); const audioPlayInterval = useRef(null);
const unmuteTimer = 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 [status, setStatus] = useState('idle');
const [detail, setDetail] = useState(null); const [detail, setDetail] = useState(null);
const [audioStatus, setAudioStatus] = useState('idle'); const [audioStatus, setAudioStatus] = useState('idle');
const [audioDetail, setAudioDetail] = useState(null); const [audioDetail, setAudioDetail] = useState(null);
const [levelIndicator, setLevelIndicator] = useState(null);
const [restartToken, setRestartToken] = useState(0); const [restartToken, setRestartToken] = useState(0);
const [audioRestartToken, setAudioRestartToken] = useState(0); const [audioRestartToken, setAudioRestartToken] = useState(0);
const [muted, setMuted] = useState(true); const [muted, setMuted] = useState(true);
const usingSnapshot = videoMode === 'snapshot'; const usingSnapshot = videoMode === 'snapshot';
const sensors = telemetryFrame?.sensors; 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 batteryCharge = sensors?.batteryChargeMah ?? null;
const desktopLayout = layoutFormat === 'desktop'; const desktopLayout = layoutFormat === 'desktop';
const mobileHud = !desktopLayout; const mobileHud = !desktopLayout;
@@ -87,6 +110,14 @@ export default function VideoTile({
const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : []; const overlayMotors = overcurrentMotors.length ? overcurrentMotors : limiterActive ? ['limiter'] : [];
const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0); const overlayFill = limiterFill ?? (overcurrentMotors.length ? 1 : 0);
const overlayVisible = Boolean(overlayMotors.length); 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 = const discordUrl =
session?.socials?.find((entry) => { session?.socials?.find((entry) => {
const key = String(entry?.id || entry?.label || '').toLowerCase(); const key = String(entry?.id || entry?.label || '').toLowerCase();
@@ -116,6 +147,50 @@ export default function VideoTile({
clearTimeout(audioRestartTimer.current); clearTimeout(audioRestartTimer.current);
audioRestartTimer.current = setTimeout(() => setAudioRestartToken(Date.now()), RESTART_DELAY_MS); 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 ensurePlayback = useCallback(async () => {
const video = videoRef.current; const video = videoRef.current;
@@ -168,6 +243,13 @@ export default function VideoTile({
clearTimeout(audioRestartTimer.current); clearTimeout(audioRestartTimer.current);
clearTimeout(unmuteTimer.current); clearTimeout(unmuteTimer.current);
clearInterval(audioPlayInterval.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]); }, [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) // Audio-only WHEP (no pausing/muting; keeps trying to play)
useEffect(() => { useEffect(() => {
if (!audioSessionInfo?.url || !audioRef.current) { if (!audioSessionInfo?.url || !audioRef.current) {
@@ -251,6 +399,7 @@ export default function VideoTile({
return nextStatus; return nextStatus;
}); });
if (nextStatus === 'playing') { if (nextStatus === 'playing') {
resumeAudioContext();
audioRef.current?.play().catch(() => {}); audioRef.current?.play().catch(() => {});
} }
if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) { if (['error', 'failed', 'disconnected', 'closed'].includes(nextStatus)) {
@@ -277,7 +426,7 @@ export default function VideoTile({
active = false; active = false;
player?.stop(); 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. // Keep nudging the audio element to play in case autoplay was blocked.
useEffect(() => { useEffect(() => {
@@ -292,10 +441,11 @@ export default function VideoTile({
return undefined; return undefined;
} }
const attemptPlay = () => { const attemptPlay = async () => {
const target = audioRef.current; const target = audioRef.current;
if (!target) return; if (!target) return;
if (!target.paused && !target.ended) return; if (!target.paused && !target.ended) return;
await resumeAudioContext();
target target
.play() .play()
.then(() => { .then(() => {
@@ -311,7 +461,7 @@ export default function VideoTile({
audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS); audioPlayInterval.current = setInterval(attemptPlay, AUDIO_RETRY_MS);
return () => clearInterval(audioPlayInterval.current); return () => clearInterval(audioPlayInterval.current);
}, [audioSessionInfo?.url, audioStatus]); }, [audioSessionInfo?.url, audioStatus, resumeAudioContext]);
// Reflect audio element events back into status/detail so the HUD stays accurate. // Reflect audio element events back into status/detail so the HUD stays accurate.
useEffect(() => { useEffect(() => {
@@ -423,6 +573,7 @@ export default function VideoTile({
label={label} label={label}
status={renderedStatus} status={renderedStatus}
audioStatus={renderedAudioStatus} audioStatus={renderedAudioStatus}
levelStatus={levelIndicator}
desktopLayout={desktopLayout} desktopLayout={desktopLayout}
layoutFormat={layoutFormat} layoutFormat={layoutFormat}
variant={hudVariant} variant={hudVariant}
@@ -537,6 +688,7 @@ function HudOverlay({
label, label,
status, status,
audioStatus, audioStatus,
levelStatus,
desktopLayout = true, desktopLayout = true,
layoutFormat = 'desktop', layoutFormat = 'desktop',
variant = 'default', variant = 'default',
@@ -626,6 +778,7 @@ function HudOverlay({
<div className="flex flex-col gap-0.5 leading-none"> <div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span> <span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null} {audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div> </div>
</div> </div>
<div <div
@@ -682,6 +835,7 @@ function HudOverlay({
<div className="flex flex-col gap-0.5 leading-none"> <div className="flex flex-col gap-0.5 leading-none">
<span>Status: {status}</span> <span>Status: {status}</span>
{audioStatus ? <span>Audio: {audioStatus}</span> : null} {audioStatus ? <span>Audio: {audioStatus}</span> : null}
{levelStatus ? <span className="text-cyan-300">{levelStatus}</span> : null}
</div> </div>
</div> </div>
{turnTimerText ? ( {turnTimerText ? (
+10 -2
View File
@@ -4,6 +4,8 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { useSocket } from './SocketContext.jsx'; import { useSocket } from './SocketContext.jsx';
import { useSession } from './SessionContext.jsx'; import { useSession } from './SessionContext.jsx';
import messageSound from '../assets/message.mp3'; import messageSound from '../assets/message.mp3';
import { useSettingsNamespace } from '../settings/index.js';
import { AUDIO_SETTINGS_DEFAULTS } from '../settings/namespaces.js';
const ChatContext = createContext({ const ChatContext = createContext({
messages: [], messages: [],
@@ -21,6 +23,7 @@ const ChatContext = createContext({
export function ChatProvider({ children }) { export function ChatProvider({ children }) {
const socket = useSocket(); const socket = useSocket();
const { session, pushAlert } = useSession(); const { session, pushAlert } = useSession();
const { value: audioSettings } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
const [messages, setMessages] = useState([]); const [messages, setMessages] = useState([]);
const [typing, setTyping] = useState([]); const [typing, setTyping] = useState([]);
const [isChatFocused, setIsChatFocused] = useState(false); const [isChatFocused, setIsChatFocused] = useState(false);
@@ -30,6 +33,9 @@ export function ChatProvider({ children }) {
const typingRef = useRef(new Map()); const typingRef = useRef(new Map());
const typingAlertRef = useRef(new Map()); const typingAlertRef = useRef(new Map());
const typingStateRef = useRef({ isTyping: false, lastSent: 0 }); 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 rebuildTyping = useCallback(() => {
const entries = Array.from(typingRef.current.values()) const entries = Array.from(typingRef.current.values())
@@ -49,15 +55,17 @@ export function ChatProvider({ children }) {
useEffect(() => { useEffect(() => {
audioRef.current = new Audio(messageSound); audioRef.current = new Audio(messageSound);
audioRef.current.volume = effectiveAlertVolume;
audioRef.current.load(); audioRef.current.load();
}, []); }, [effectiveAlertVolume]);
const playSound = useCallback(() => { const playSound = useCallback(() => {
const audio = audioRef.current; const audio = audioRef.current;
if (!audio) return; if (!audio) return;
audio.volume = effectiveAlertVolume;
audio.currentTime = 0; audio.currentTime = 0;
audio.play().catch(() => {}); audio.play().catch(() => {});
}, []); }, [effectiveAlertVolume]);
useEffect(() => { useEffect(() => {
function handleMessage(payload = {}) { function handleMessage(payload = {}) {
+3
View File
@@ -9,12 +9,14 @@ import { TelemetryProvider } from './context/TelemetryContext.jsx'
import { ChatProvider } from './context/ChatContext.jsx' import { ChatProvider } from './context/ChatContext.jsx'
import SpectatorApp from './spectate/SpectatorApp.jsx' import SpectatorApp from './spectate/SpectatorApp.jsx'
import MiniSummaryApp from './mini/MiniSummaryApp.jsx' import MiniSummaryApp from './mini/MiniSummaryApp.jsx'
import { SettingsProvider } from './settings/index.js'
createRoot(document.getElementById('root')).render( createRoot(document.getElementById('root')).render(
<StrictMode> <StrictMode>
<SocketProvider> <SocketProvider>
<SessionProvider> <SessionProvider>
<TelemetryProvider> <TelemetryProvider>
<SettingsProvider>
<ChatProvider> <ChatProvider>
<BrowserRouter> <BrowserRouter>
<Routes> <Routes>
@@ -24,6 +26,7 @@ createRoot(document.getElementById('root')).render(
</Routes> </Routes>
</BrowserRouter> </BrowserRouter>
</ChatProvider> </ChatProvider>
</SettingsProvider>
</TelemetryProvider> </TelemetryProvider>
</SessionProvider> </SessionProvider>
</SocketProvider> </SocketProvider>
-3
View File
@@ -1,5 +1,4 @@
import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
import { SettingsProvider } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useTelemetryFrames } from '../context/TelemetryContext.jsx'; import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
import { useVideoRequests } from '../hooks/useVideoRequests.js'; import { useVideoRequests } from '../hooks/useVideoRequests.js';
@@ -410,12 +409,10 @@ export default function MiniSummaryApp() {
}, []); }, []);
return ( return (
<SettingsProvider>
<> <>
<MiniSummaryContent /> <MiniSummaryContent />
<AlertFeed scale={3} /> <AlertFeed scale={3} />
</> </>
</SettingsProvider>
); );
} }
+8
View File
@@ -90,3 +90,11 @@ export const HORN_SETTINGS_DEFAULTS = {
waveform: 'saw', waveform: 'saw',
freqs: [440, 550, 660, 0], 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 { useEffect, useState } from 'react';
import { SettingsProvider } from '../settings/index.js';
import { useSession } from '../context/SessionContext.jsx'; import { useSession } from '../context/SessionContext.jsx';
import { useSpectatorMode } from '../hooks/useSpectatorMode.js'; import { useSpectatorMode } from '../hooks/useSpectatorMode.js';
import { useTelemetryFrames } from '../context/TelemetryContext.jsx'; import { useTelemetryFrames } from '../context/TelemetryContext.jsx';
@@ -222,9 +221,5 @@ function SpectatorContent() {
} }
export default function SpectatorApp() { export default function SpectatorApp() {
return ( return <SpectatorContent />;
<SettingsProvider>
<SpectatorContent />
</SettingsProvider>
);
} }