mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-17 10:00:46 -04:00
volume stuff
This commit is contained in:
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
||||||
|
|||||||
@@ -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]);
|
||||||
|
|||||||
@@ -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 ? (
|
||||||
|
|||||||
@@ -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 = {}) {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user