mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-11-14 18:58:49 -05:00
room audio
This commit is contained in:
@@ -0,0 +1,69 @@
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useSessionSelector } from '../../context/SessionContext.jsx';
|
||||
import { useSocket } from '../../context/SocketContext.jsx';
|
||||
import { useSettingsNamespace } from '../../settings/index.js';
|
||||
import { AUDIO_SETTINGS_DEFAULTS } from '../../settings/namespaces.js';
|
||||
import { RESTART_DELAY_MS, startWhepPlayback } from '../../lib/whepPlayback.js';
|
||||
import useWhepRestart from '../../hooks/useWhepRestart.js';
|
||||
|
||||
function StreamPlayer({ id, volume }) {
|
||||
const socket = useSocket();
|
||||
const audio = useRef(null);
|
||||
const { restartToken, scheduleRestart } = useWhepRestart();
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
let stop;
|
||||
let token;
|
||||
socket.timeout(5000).emit('video:request', { type: 'roomAudio', id }, (error, response = {}) => {
|
||||
if (!active) {
|
||||
if (response.token && socket.connected) socket.emit('roomAudio:release', { token: response.token });
|
||||
return;
|
||||
}
|
||||
token = response.token;
|
||||
if (error || response.error || !response.url) { scheduleRestart(); return; }
|
||||
stop = startWhepPlayback({
|
||||
...response, video: audio.current, audioOnly: true,
|
||||
onStatus: () => {}, onError: () => {}, scheduleRestart,
|
||||
});
|
||||
});
|
||||
return () => {
|
||||
active = false; stop?.();
|
||||
if (token && socket.connected) socket.emit('roomAudio:release', { token });
|
||||
};
|
||||
}, [id, socket, restartToken, scheduleRestart]);
|
||||
useEffect(() => { if (audio.current) audio.current.volume = Math.max(0, Math.min(1, volume)); }, [volume]);
|
||||
return <audio ref={audio} autoPlay />;
|
||||
}
|
||||
|
||||
export default function RoomAudioPlayer() {
|
||||
const container = useRef(null);
|
||||
const streams = useSessionSelector((state) => state.session?.roomAudioStreams);
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const { value } = useSettingsNamespace('audio', AUDIO_SETTINGS_DEFAULTS);
|
||||
const volume = (value?.masterVolume ?? 1) * (value?.roomVolume ?? 1);
|
||||
const play = useCallback(() => {
|
||||
const root = container.current;
|
||||
if (!root) return;
|
||||
const elements = [...root.querySelectorAll('audio')].filter((audio) => audio.srcObject);
|
||||
if (!elements.length) return;
|
||||
// Call play during the gesture; blocked playback retries on the interval.
|
||||
elements.forEach((audio) => { audio.play().catch(() => {}); });
|
||||
}, []);
|
||||
useEffect(() => {
|
||||
if (!connected) return undefined;
|
||||
const timer = setInterval(play, RESTART_DELAY_MS);
|
||||
window.addEventListener('pointerdown', play);
|
||||
window.addEventListener('keydown', play);
|
||||
return () => {
|
||||
clearInterval(timer);
|
||||
window.removeEventListener('pointerdown', play);
|
||||
window.removeEventListener('keydown', play);
|
||||
};
|
||||
}, [connected, play]);
|
||||
if (!connected) return null;
|
||||
return (
|
||||
<div ref={container} className="contents">
|
||||
{(streams || []).map((stream) => <StreamPlayer key={stream.id} id={stream.id} volume={volume} />)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -159,6 +159,7 @@ export default function SettingsPanel() {
|
||||
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 roomVolume = Number.isFinite(audioSettings?.roomVolume) ? audioSettings.roomVolume : AUDIO_SETTINGS_DEFAULTS.roomVolume;
|
||||
const mainBrushDuckEnabled =
|
||||
typeof audioSettings?.mainBrushDuckEnabled === 'boolean'
|
||||
? audioSettings.mainBrushDuckEnabled
|
||||
@@ -440,6 +441,11 @@ export default function SettingsPanel() {
|
||||
value={roverVolume}
|
||||
onChange={handleAudioRange('roverVolume')}
|
||||
/>
|
||||
<RangeSetting
|
||||
label="Room audio"
|
||||
value={roomVolume}
|
||||
onChange={handleAudioRange('roomVolume')}
|
||||
/>
|
||||
<SettingRow>
|
||||
<span className="font-semibold text-white">Main brush ducking</span>
|
||||
<input
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Server Display App Root
|
||||
// Purpose: Defines the route root for the physical-room display page.
|
||||
// Scope: Keeps this passive spectator-style surface separate from the main driver UI.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import ServerDisplayContent from './ServerDisplayContent.jsx';
|
||||
|
||||
export default function ServerDisplayAppRoot() {
|
||||
return <ServerDisplayContent />;
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<ServerDisplayContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// Main Application Shell
|
||||
// Purpose: Composes the primary rover control interface and page-level layout. Scope: Orchestrates high-level panels, overlays, and feature modules for the default route.
|
||||
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import {
|
||||
@@ -90,11 +91,7 @@ function DriverPageContent({ layout, oldDesktop }) {
|
||||
);
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
{/*
|
||||
Keep document-exit protection inside the control provider so it follows
|
||||
the same assigned-rover identity as every driving command. Mounting it
|
||||
only on the driver page leaves spectator and utility routes unchanged.
|
||||
*/}
|
||||
<RoomAudioPlayer />
|
||||
<UndockedPageExitGuard />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
|
||||
@@ -42,6 +42,7 @@ async function buildSocketIdentity() {
|
||||
audioAdjustments: currentSettings?.audioAdjustments || {},
|
||||
overseerEnabled: Boolean(currentSettings?.overseerPreference?.enabled),
|
||||
identitySurface: getIdentitySurface(),
|
||||
role: window.location.pathname === '/mic' ? 'spectator' : 'user',
|
||||
operatingMode: window.location.pathname === '/ptz' ? 'ptz' : 'rover',
|
||||
};
|
||||
}
|
||||
|
||||
@@ -20,6 +20,7 @@ import { SettingsProvider } from './settings/index.js'
|
||||
import DeterrenceChaos from './components/DeterrenceChaos/index.jsx'
|
||||
import AnalyticsReporter from './analytics/AnalyticsReporter.jsx'
|
||||
import PtzAppRoot from './ptz/PtzAppRoot.jsx'
|
||||
import MicApp from './mic/MicApp.jsx'
|
||||
import OperatingModeRouteSync from './components/OperatingModeRouteSync/index.jsx'
|
||||
import InitialSessionOverlay from './components/InitialSessionOverlay/index.jsx'
|
||||
import AppErrorBoundary from './components/AppErrorBoundary.jsx'
|
||||
@@ -65,6 +66,7 @@ createRoot(document.getElementById('root')).render(
|
||||
<Route path="/spectate" element={<SpectatorApp />} />
|
||||
<Route path="/mini" element={<MiniSummaryApp />} />
|
||||
<Route path="/display" element={<ServerDisplayApp />} />
|
||||
<Route path="/mic" element={<MicApp />} />
|
||||
<Route path="/scanner" element={<ScannerApp />} />
|
||||
<Route path="/setup" element={lazyRoute(<SetupApp />, 'setup')} />
|
||||
<Route path="/admin" element={lazyRoute(<AdminApp />, 'administration')} />
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useSocket } from '../context/SocketContext.jsx';
|
||||
import { useSessionSelector } from '../context/SessionContext.jsx';
|
||||
import useUserIdentitySync from '../hooks/useUserIdentitySync.js';
|
||||
import { RESTART_DELAY_MS } from '../lib/whepPlayback.js';
|
||||
import { waitForIceGatheringComplete } from '../components/vip/VipAudioUploadCard/whipTransport.js';
|
||||
|
||||
export default function MicApp() {
|
||||
const socket = useSocket();
|
||||
const connected = useSessionSelector((state) => state.connected);
|
||||
const local = useSessionSelector((state) => state.session?.isLocalNetwork);
|
||||
const enabled = useSessionSelector((state) => state.session?.features?.roomAudio);
|
||||
const [status, setStatus] = useState('Connecting…');
|
||||
useUserIdentitySync();
|
||||
useEffect(() => {
|
||||
if (!connected || !local || !enabled) return undefined;
|
||||
let disposed = false;
|
||||
let attempt;
|
||||
let media;
|
||||
let retryTimer;
|
||||
const ack = (event, payload = {}) => new Promise((resolve, reject) => {
|
||||
socket.timeout(5000).emit(event, payload, (error, result = {}) => {
|
||||
if (error || result.error) reject(error || new Error(result.error));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
const isCurrent = (current) => !disposed && Boolean(current) && attempt === current;
|
||||
function closeAttempt() {
|
||||
const current = attempt;
|
||||
attempt = null;
|
||||
if (!current) return;
|
||||
clearTimeout(current.timer);
|
||||
current.abort.abort();
|
||||
if (current.peer) {
|
||||
current.peer.onconnectionstatechange = null;
|
||||
current.peer.close();
|
||||
}
|
||||
if (current.resource) fetch(current.resource, { method: 'DELETE', headers: current.headers, keepalive: true }).catch(() => {});
|
||||
}
|
||||
function retry(current, message) {
|
||||
if (!isCurrent(current)) return;
|
||||
closeAttempt();
|
||||
clearTimeout(retryTimer);
|
||||
setStatus(message);
|
||||
retryTimer = setTimeout(start, RESTART_DELAY_MS);
|
||||
}
|
||||
async function start() {
|
||||
if (disposed) return;
|
||||
// Invalidate the previous attempt before closing it. Late async results
|
||||
// and peer events must never change the new attempt's state or timers.
|
||||
closeAttempt();
|
||||
const current = { abort: new AbortController() };
|
||||
attempt = current;
|
||||
setStatus('Starting microphone…');
|
||||
try {
|
||||
await ack('session:setRole', { role: 'spectator' });
|
||||
if (!isCurrent(current)) return;
|
||||
if (!media || media.getAudioTracks().every((track) => track.readyState === 'ended')) {
|
||||
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
|
||||
if (!navigator.mediaDevices?.getUserMedia) throw new Error('Microphone access requires HTTPS or localhost');
|
||||
const captured = await navigator.mediaDevices.getUserMedia({ audio: true });
|
||||
if (!isCurrent(current)) { captured.getTracks().forEach((track) => track.stop()); return; }
|
||||
media = captured;
|
||||
media.getAudioTracks().forEach((track) => { track.onended = () => retry(attempt, 'Microphone disconnected. Retrying…'); });
|
||||
}
|
||||
const source = await ack('roomAudio:start');
|
||||
if (!isCurrent(current)) return;
|
||||
current.headers = { Authorization: `Basic ${btoa(`${source.token}:${source.token}`)}` };
|
||||
const pc = new RTCPeerConnection({ iceServers: [{ urls: 'stun:stun.l.google.com:19302' }] });
|
||||
current.peer = pc;
|
||||
media.getAudioTracks().forEach((track) => pc.addTrack(track, media));
|
||||
pc.onconnectionstatechange = () => {
|
||||
if (!isCurrent(current)) return;
|
||||
if (pc.connectionState === 'connected') {
|
||||
clearTimeout(current.timer);
|
||||
setStatus('Microphone live');
|
||||
}
|
||||
if (['failed', 'disconnected', 'closed'].includes(pc.connectionState)) retry(current, 'Connection lost. Retrying…');
|
||||
};
|
||||
await pc.setLocalDescription(await pc.createOffer());
|
||||
if (!isCurrent(current)) return;
|
||||
await waitForIceGatheringComplete(pc);
|
||||
if (!isCurrent(current)) return;
|
||||
const response = await fetch(source.url, { method: 'POST', headers: { ...current.headers, 'Content-Type': 'application/sdp' }, body: pc.localDescription.sdp, signal: AbortSignal.any([current.abort.signal, AbortSignal.timeout(10000)]) });
|
||||
if (!response.ok) throw new Error(`Microphone connection failed (${response.status})`);
|
||||
const location = response.headers.get('Location');
|
||||
current.resource = location ? new URL(location, new URL(source.url, window.location.href)).href : null;
|
||||
if (!isCurrent(current)) {
|
||||
if (current.resource) fetch(current.resource, { method: 'DELETE', headers: current.headers, keepalive: true }).catch(() => {});
|
||||
return;
|
||||
}
|
||||
const sdp = await response.text();
|
||||
if (!isCurrent(current)) return;
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp });
|
||||
if (!isCurrent(current)) return;
|
||||
// A negotiation can hang without reaching a terminal peer state.
|
||||
if (pc.connectionState !== 'connected') current.timer = setTimeout(() => retry(current, 'Connection timed out. Retrying…'), 10000);
|
||||
} catch (error) { retry(current, `${error.message}. Retrying…`); }
|
||||
}
|
||||
start();
|
||||
return () => {
|
||||
disposed = true;
|
||||
clearTimeout(retryTimer);
|
||||
closeAttempt();
|
||||
media?.getTracks().forEach((track) => { track.onended = null; track.stop(); });
|
||||
if (socket.connected) socket.emit('roomAudio:stop', {});
|
||||
};
|
||||
}, [socket, connected, local, enabled]);
|
||||
const message = !connected ? 'Connecting…' : local === false ? 'This page is available to local visitors only.' : !enabled ? 'Room audio is disabled.' : status;
|
||||
return <main className="min-h-screen bg-neutral-950 p-4 text-slate-200"><div className="panel mx-auto max-w-md p-4"><h1 className="text-lg font-semibold">Room microphone</h1><p className="mt-2 text-sm">{message}</p></div></main>;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
// Mini Summary App Root
|
||||
// Purpose: Defines the Mini Summary App Root module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import { useEffect } from 'react';
|
||||
import AlertFeed from '../../components/AlertFeed/index.jsx';
|
||||
import MiniSummaryContent from './MiniSummaryContent.jsx';
|
||||
@@ -19,6 +20,7 @@ export default function MiniSummaryAppRoot() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<MiniSummaryContent />
|
||||
<AlertFeed scale={3} opacity={1}/>
|
||||
</>
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
// Purpose: Mounts the PTZ controller as a real page with the same shared input
|
||||
// and identity systems used by the driver page.
|
||||
// Scope: Mounts input/identity providers; the page owns session entry and layouts.
|
||||
import RoomAudioPlayer from '../components/RoomAudioPlayer/index.jsx';
|
||||
import AlertFeed from '../components/AlertFeed/index.jsx';
|
||||
import SocketConnectionPill from '../components/SocketConnectionPill/index.jsx';
|
||||
import { LayoutProvider } from '../layouts/LayoutContext.jsx';
|
||||
@@ -25,6 +26,7 @@ function PtzRouteContent() {
|
||||
|
||||
return (
|
||||
<ControlSystemProvider>
|
||||
<RoomAudioPlayer />
|
||||
<KeyboardInputManager />
|
||||
<GamepadInputManager />
|
||||
<LayoutProvider layout={layout}>
|
||||
|
||||
@@ -176,6 +176,7 @@ export const AUDIO_SETTINGS_DEFAULTS = {
|
||||
masterVolume: 1,
|
||||
alertVolume: 0.5,
|
||||
roverVolume: 1,
|
||||
roomVolume: 1,
|
||||
mainBrushDuckEnabled: true,
|
||||
mainBrushDuckAmount: 0.75,
|
||||
};
|
||||
|
||||
@@ -1,8 +1,14 @@
|
||||
// Spectator App Root
|
||||
// Purpose: Defines the Spectator App Root module and the local helpers/components used in this file.
|
||||
// Scope: Keeps behavior unchanged while isolating this concern into a clear, single-responsibility unit.
|
||||
import RoomAudioPlayer from '../../components/RoomAudioPlayer/index.jsx';
|
||||
import SpectatorContent from './SpectatorContent.jsx';
|
||||
|
||||
export default function SpectatorAppRoot() {
|
||||
return <SpectatorContent />;
|
||||
return (
|
||||
<>
|
||||
<RoomAudioPlayer />
|
||||
<SpectatorContent />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user