This commit is contained in:
legop3
2026-03-17 19:35:36 -04:00
parent 618fd3e4de
commit 93204ec1f1
12 changed files with 529 additions and 137 deletions
+1
View File
@@ -24,6 +24,7 @@ const KEY_ACTIONS = [
{ id: 'cameraDown', label: 'Camera Down', group: 'Camera' },
{ id: 'nightVisionToggle', label: 'Toggle Night Vision', group: 'Camera' },
{ id: 'hornHonk', label: 'Horn (Hold)', group: 'Audio' },
{ id: 'micPtt', label: 'Mic Push To Talk', group: 'Audio' },
{ id: 'driveMacro', label: 'Drive Macro', group: 'Macros' },
{ id: 'dockMacro', label: 'Dock Macro', group: 'Macros' },
{ id: 'chatFocus', label: 'Toggle Chat', group: 'Chat' },
+13 -1
View File
@@ -7,7 +7,16 @@ import VipVerificationCard from './vip/VipVerificationCard.jsx';
import VipIdentityCard from './vip/VipIdentityCard.jsx';
export default function VipPanel() {
const { session, identifySession, requestVerification, playUploadedAudio, stopUploadedAudio } = useSession();
const {
session,
identifySession,
requestVerification,
playUploadedAudio,
stopUploadedAudio,
startMicForward,
stopMicForward,
sendMicChunk,
} = useSession();
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
@@ -45,6 +54,9 @@ export default function VipPanel() {
audioForwardByRover={session?.audioForward || {}}
playUploadedAudio={playUploadedAudio}
stopUploadedAudio={stopUploadedAudio}
startMicForward={startMicForward}
stopMicForward={stopMicForward}
sendMicChunk={sendMicChunk}
/>
) : (
<VipVerificationCard
@@ -1,4 +1,4 @@
import { useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import {
MAX_UPLOAD_BYTES,
bytesToBase64,
@@ -6,6 +6,7 @@ import {
flowWrapClass,
innerFlowClass,
} from './constants.js';
import { useControlSystem } from '../../controls/index.js';
export default function VipAudioForwardingCard({
roster = [],
@@ -13,12 +14,23 @@ export default function VipAudioForwardingCard({
audioForwardByRover = {},
playUploadedAudio,
stopUploadedAudio,
startMicForward,
stopMicForward,
sendMicChunk,
}) {
const { state: controlState } = useControlSystem();
const [selectedUpload, setSelectedUpload] = useState(null);
const [working, setWorking] = useState(false);
const [openMicEnabled, setOpenMicEnabled] = useState(false);
const [micState, setMicState] = useState('idle');
const [message, setMessage] = useState('');
const recorderRef = useRef(null);
const streamRef = useRef(null);
const micActiveRef = useRef(false);
const activeRoverRef = useRef('');
const singleRoverId = roster.length === 1 ? roster[0].id : '';
const targetRoverId = String(singleRoverId || ownRoverId || '').trim();
const pttActive = Boolean(controlState?.mic?.pttActive);
const selectedForwardState = useMemo(
() => (targetRoverId ? audioForwardByRover?.[targetRoverId] || null : null),
[audioForwardByRover, targetRoverId],
@@ -75,6 +87,137 @@ export default function VipAudioForwardingCard({
}
};
const stopMicCapture = useCallback(
async (roverId) => {
const target = String(roverId || activeRoverRef.current || '').trim();
micActiveRef.current = false;
setMicState('idle');
try {
const recorder = recorderRef.current;
if (recorder && recorder.state !== 'inactive') {
recorder.stop();
}
} catch {
// noop
}
recorderRef.current = null;
if (streamRef.current) {
try {
streamRef.current.getTracks().forEach((track) => track.stop());
} catch {
// noop
}
}
streamRef.current = null;
if (target) {
try {
await stopMicForward?.(target);
} catch {
// noop
}
}
activeRoverRef.current = '';
},
[stopMicForward],
);
const startMicCapture = useCallback(
async (roverId) => {
const target = String(roverId || '').trim();
if (!target) {
throw new Error('Take control of a rover first.');
}
if (!navigator.mediaDevices?.getUserMedia) {
throw new Error('Microphone capture is not supported in this browser.');
}
if (typeof MediaRecorder === 'undefined') {
throw new Error('MediaRecorder is not supported in this browser.');
}
await stopMicCapture(target);
setMicState('starting');
let stream = null;
try {
stream = await navigator.mediaDevices.getUserMedia({
audio: {
channelCount: 1,
echoCancellation: true,
noiseSuppression: true,
autoGainControl: true,
},
});
streamRef.current = stream;
await startMicForward?.(target);
const mimeType = MediaRecorder.isTypeSupported('audio/webm;codecs=opus')
? 'audio/webm;codecs=opus'
: 'audio/webm';
const recorder = new MediaRecorder(stream, { mimeType, audioBitsPerSecond: 64000 });
recorderRef.current = recorder;
micActiveRef.current = true;
activeRoverRef.current = target;
recorder.ondataavailable = async (event) => {
try {
if (!micActiveRef.current || !event.data || event.data.size <= 0) return;
const buffer = await event.data.arrayBuffer();
const base64 = bytesToBase64(new Uint8Array(buffer));
sendMicChunk?.({ roverId: target, dataBase64: base64 });
} catch {
// noop
}
};
recorder.onstop = () => {
if (!micActiveRef.current) return;
micActiveRef.current = false;
setMicState('idle');
};
recorder.start(120);
setMicState('live');
} catch (err) {
if (stream) {
try {
stream.getTracks().forEach((track) => track.stop());
} catch {
// noop
}
}
streamRef.current = null;
throw err;
}
},
[sendMicChunk, startMicForward, stopMicCapture],
);
useEffect(() => {
const desiredActive = Boolean(openMicEnabled || pttActive);
const roverId = targetRoverId;
let cancelled = false;
async function syncMicState() {
if (!roverId || !desiredActive) {
await stopMicCapture(activeRoverRef.current || roverId);
return;
}
if (micActiveRef.current && activeRoverRef.current === roverId) return;
try {
await startMicCapture(roverId);
} catch (err) {
if (!cancelled) {
setMicState('error');
setMessage(err?.message || 'Failed to start microphone forwarding.');
}
}
}
syncMicState();
return () => {
cancelled = true;
};
}, [openMicEnabled, pttActive, startMicCapture, stopMicCapture, targetRoverId]);
useEffect(
() => () => {
stopMicCapture(activeRoverRef.current);
},
[stopMicCapture],
);
return (
<section className={`surface ${flowWrapClass}`}>
<div className={innerFlowClass}>
@@ -102,6 +245,20 @@ export default function VipAudioForwardingCard({
Stop
</button>
</div>
<div className="surface-muted mx-auto flex w-full max-w-sm flex-col gap-0.5 p-0.5 text-xs text-slate-300 text-center">
<p className="text-slate-200">Microphone Forwarding</p>
<label className="flex items-center justify-center gap-0.5">
<input
type="checkbox"
checked={openMicEnabled}
disabled={!targetRoverId}
onChange={(event) => setOpenMicEnabled(Boolean(event.target.checked))}
/>
<span>Open mic</span>
</label>
<p className="text-slate-400">PTT key: {controlState?.keymap?.micPtt?.[0] || 'v'} (hold)</p>
<p className="text-slate-400">mic: {micState}</p>
</div>
{selectedForwardState ? (
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
state: {selectedForwardState.state || 'idle'}