mirror of
https://github.com/legop3/MultiRoombaRover.git
synced 2026-09-16 01:21:20 -04:00
the removal...
This commit is contained in:
@@ -337,14 +337,6 @@ export default function AdminPanel() {
|
||||
>
|
||||
{rebootStates[rover.id] ? 'Rebooting...' : 'Reboot'}
|
||||
</button>
|
||||
<span className="surface-muted">
|
||||
audio: {session?.audioForward?.[rover.id]?.state || 'idle'}
|
||||
</span>
|
||||
{session?.audioForward?.[rover.id]?.error ? (
|
||||
<span className="surface-muted text-rose-300">
|
||||
{String(session.audioForward[rover.id].error)}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
/>
|
||||
|
||||
@@ -24,7 +24,6 @@ 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' },
|
||||
|
||||
@@ -2,24 +2,11 @@ import { useMemo, useState } from 'react';
|
||||
import { useSession } from '../context/SessionContext.jsx';
|
||||
import { useSettingsNamespace } from '../settings/index.js';
|
||||
import { COOKIE_KEY_REGEX, flowWrapClass } from './vip/constants.js';
|
||||
import VipAudioForwardingCard from './vip/VipAudioForwardingCard.jsx';
|
||||
import VipVerificationCard from './vip/VipVerificationCard.jsx';
|
||||
import VipIdentityCard from './vip/VipIdentityCard.jsx';
|
||||
|
||||
export default function VipPanel() {
|
||||
const {
|
||||
session,
|
||||
identifySession,
|
||||
requestVerification,
|
||||
playUploadedAudio,
|
||||
stopUploadedAudio,
|
||||
startMicForward,
|
||||
stopMicForward,
|
||||
sendMicChunk,
|
||||
startMicWhip,
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
} = useSession();
|
||||
const { session, identifySession, requestVerification } = useSession();
|
||||
const { value: identity, save: saveIdentity } = useSettingsNamespace('identity', { cookieUserId: '' });
|
||||
const { value: profile } = useSettingsNamespace('profile', { nickname: '' });
|
||||
|
||||
@@ -27,12 +14,6 @@ export default function VipPanel() {
|
||||
const nickname = (profile?.nickname || '').trim();
|
||||
const isVerified = Boolean(session?.isVerified);
|
||||
const pendingRequestId = session?.verification?.pendingRequestId || null;
|
||||
const roster = useMemo(() => session?.roster ?? [], [session?.roster]);
|
||||
const ownRoverId = String(session?.assignment?.roverId || '').trim();
|
||||
const ownRoverRoster = useMemo(
|
||||
() => (ownRoverId ? roster.filter((rover) => rover.id === ownRoverId) : []),
|
||||
[ownRoverId, roster],
|
||||
);
|
||||
const [message, setMessage] = useState('');
|
||||
|
||||
const applyIdentityKey = async (nextRaw) => {
|
||||
@@ -51,19 +32,12 @@ export default function VipPanel() {
|
||||
return (
|
||||
<section className="panel-section space-y-0.5 text-base">
|
||||
{isVerified ? (
|
||||
<VipAudioForwardingCard
|
||||
roster={ownRoverRoster}
|
||||
ownRoverId={ownRoverId}
|
||||
audioForwardByRover={session?.audioForward || {}}
|
||||
playUploadedAudio={playUploadedAudio}
|
||||
stopUploadedAudio={stopUploadedAudio}
|
||||
startMicForward={startMicForward}
|
||||
stopMicForward={stopMicForward}
|
||||
sendMicChunk={sendMicChunk}
|
||||
startMicWhip={startMicWhip}
|
||||
readyMicWhip={readyMicWhip}
|
||||
stopMicWhip={stopMicWhip}
|
||||
/>
|
||||
<div className={`surface ${flowWrapClass}`}>
|
||||
<div className="mx-auto flex w-full max-w-md flex-col items-center space-y-0.5 text-center">
|
||||
<p className="text-sm text-slate-300">VIP verified.</p>
|
||||
<p className="text-xs text-slate-500">Audio forwarding is disabled while this stack is being rebuilt.</p>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<VipVerificationCard
|
||||
pendingRequestId={pendingRequestId}
|
||||
|
||||
@@ -1,631 +0,0 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
MAX_UPLOAD_BYTES,
|
||||
bytesToBase64,
|
||||
fieldClass,
|
||||
flowWrapClass,
|
||||
innerFlowClass,
|
||||
} from './constants.js';
|
||||
import { useControlSystem } from '../../controls/index.js';
|
||||
|
||||
const TARGET_SAMPLE_RATE = 16000;
|
||||
const MIC_PACKET_MS = 40;
|
||||
const MIC_PACKET_BYTES = (TARGET_SAMPLE_RATE * 2 * MIC_PACKET_MS) / 1000; // s16le mono
|
||||
const RTC_CONFIG = {
|
||||
iceServers: [{ urls: 'stun:stun.l.google.com:19302' }],
|
||||
bundlePolicy: 'max-bundle',
|
||||
rtcpMuxPolicy: 'require',
|
||||
};
|
||||
|
||||
function encodeBase64(value) {
|
||||
if (typeof btoa === 'function') return btoa(value);
|
||||
return '';
|
||||
}
|
||||
|
||||
function buildAuthHeader(token) {
|
||||
if (!token) return {};
|
||||
const encoded = encodeBase64(`${token}:${token}`);
|
||||
return encoded ? { Authorization: `Basic ${encoded}` } : {};
|
||||
}
|
||||
|
||||
function waitForIceGatheringComplete(pc, timeoutMs = 1500) {
|
||||
return new Promise((resolve) => {
|
||||
if (!pc || pc.iceGatheringState === 'complete') {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}, timeoutMs);
|
||||
function onChange() {
|
||||
if (pc.iceGatheringState === 'complete') {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('icegatheringstatechange', onChange);
|
||||
resolve();
|
||||
}
|
||||
}
|
||||
pc.addEventListener('icegatheringstatechange', onChange);
|
||||
});
|
||||
}
|
||||
|
||||
function isPeerTransportReady(pc) {
|
||||
if (!pc) return false;
|
||||
const conn = pc.connectionState;
|
||||
const ice = pc.iceConnectionState;
|
||||
if (conn === 'connected') return true;
|
||||
if (ice === 'connected' || ice === 'completed') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
function waitForPeerConnected(pc, timeoutMs = 10000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
if (isPeerTransportReady(pc)) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
cleanup();
|
||||
reject(new Error('Peer connection timeout'));
|
||||
}, timeoutMs);
|
||||
const onState = () => {
|
||||
if (isPeerTransportReady(pc)) {
|
||||
cleanup();
|
||||
resolve();
|
||||
} else if (
|
||||
pc.connectionState === 'failed' ||
|
||||
pc.connectionState === 'closed' ||
|
||||
pc.iceConnectionState === 'failed'
|
||||
) {
|
||||
cleanup();
|
||||
reject(new Error(`Peer connection ${pc.connectionState || pc.iceConnectionState}`));
|
||||
}
|
||||
};
|
||||
function cleanup() {
|
||||
clearTimeout(timer);
|
||||
pc.removeEventListener('connectionstatechange', onState);
|
||||
pc.removeEventListener('iceconnectionstatechange', onState);
|
||||
}
|
||||
pc.addEventListener('connectionstatechange', onState);
|
||||
pc.addEventListener('iceconnectionstatechange', onState);
|
||||
});
|
||||
}
|
||||
|
||||
async function configureSenderForLowLatency(sender) {
|
||||
if (!sender?.getParameters || !sender?.setParameters) return;
|
||||
const params = sender.getParameters() || {};
|
||||
const first = (params.encodings && params.encodings[0]) || {};
|
||||
params.encodings = [
|
||||
{
|
||||
...first,
|
||||
maxBitrate: 64000,
|
||||
dtx: 'disabled',
|
||||
},
|
||||
];
|
||||
try {
|
||||
await sender.setParameters(params);
|
||||
} catch {
|
||||
// Some browsers reject unsupported combinations; keep defaults.
|
||||
}
|
||||
}
|
||||
|
||||
function waitForOutboundAudioFlow(pc, timeoutMs = 6000) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!pc) {
|
||||
reject(new Error('Peer connection missing'));
|
||||
return;
|
||||
}
|
||||
const start = Date.now();
|
||||
let baseline = -1;
|
||||
const timer = setInterval(async () => {
|
||||
if (Date.now() - start > timeoutMs) {
|
||||
clearInterval(timer);
|
||||
reject(new Error('WHIP connected but no outbound audio flow'));
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const senders = pc.getSenders().filter((s) => s.track?.kind === 'audio');
|
||||
for (const sender of senders) {
|
||||
const stats = await sender.getStats();
|
||||
for (const report of stats.values()) {
|
||||
if (report.type !== 'outbound-rtp' || report.kind !== 'audio') continue;
|
||||
const sent = Number(report.bytesSent || 0);
|
||||
const packets = Number(report.packetsSent || 0);
|
||||
if (baseline < 0) {
|
||||
baseline = sent;
|
||||
} else if (sent > baseline + 200 || packets > 5) {
|
||||
clearInterval(timer);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Keep polling until timeout.
|
||||
}
|
||||
}, 250);
|
||||
});
|
||||
}
|
||||
|
||||
function resampleTo16k(input, sampleRate) {
|
||||
if (!input || !input.length) return new Float32Array(0);
|
||||
if (sampleRate === TARGET_SAMPLE_RATE) return input;
|
||||
if (!Number.isFinite(sampleRate) || sampleRate <= 0) return input;
|
||||
const ratio = sampleRate / TARGET_SAMPLE_RATE;
|
||||
const outputLength = Math.max(1, Math.round(input.length / ratio));
|
||||
const output = new Float32Array(outputLength);
|
||||
for (let outIdx = 0; outIdx < outputLength; outIdx += 1) {
|
||||
const src = outIdx * ratio;
|
||||
const srcFloor = Math.floor(src);
|
||||
const srcCeil = Math.min(input.length - 1, srcFloor + 1);
|
||||
const frac = src - srcFloor;
|
||||
const a = input[srcFloor] ?? 0;
|
||||
const b = input[srcCeil] ?? a;
|
||||
output[outIdx] = a + (b - a) * frac;
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
function floatToInt16Bytes(floatSamples) {
|
||||
const bytes = new Uint8Array(floatSamples.length * 2);
|
||||
const view = new DataView(bytes.buffer);
|
||||
for (let i = 0; i < floatSamples.length; i += 1) {
|
||||
const sample = Math.max(-1, Math.min(1, floatSamples[i]));
|
||||
const int16 = sample < 0 ? Math.round(sample * 0x8000) : Math.round(sample * 0x7fff);
|
||||
view.setInt16(i * 2, int16, true);
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function concatUint8(chunks = [], totalLength = 0) {
|
||||
const out = new Uint8Array(totalLength);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
out.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export default function VipAudioForwardingCard({
|
||||
roster = [],
|
||||
ownRoverId = '',
|
||||
audioForwardByRover = {},
|
||||
playUploadedAudio,
|
||||
stopUploadedAudio,
|
||||
startMicForward,
|
||||
stopMicForward,
|
||||
sendMicChunk,
|
||||
startMicWhip,
|
||||
readyMicWhip,
|
||||
stopMicWhip,
|
||||
}) {
|
||||
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 streamRef = useRef(null);
|
||||
const audioContextRef = useRef(null);
|
||||
const mediaSourceRef = useRef(null);
|
||||
const processorRef = useRef(null);
|
||||
const sinkRef = useRef(null);
|
||||
const pendingPcmChunksRef = useRef([]);
|
||||
const pendingPcmBytesRef = useRef(0);
|
||||
const whipPcRef = useRef(null);
|
||||
const micTransportRef = useRef('none');
|
||||
const whipFailoverRef = useRef(false);
|
||||
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],
|
||||
);
|
||||
|
||||
const handleUploadPlay = async () => {
|
||||
const roverId = targetRoverId;
|
||||
if (!roverId) {
|
||||
setMessage('Take control of a rover first.');
|
||||
return;
|
||||
}
|
||||
if (!selectedUpload) {
|
||||
setMessage('Select an audio file first.');
|
||||
return;
|
||||
}
|
||||
if (selectedUpload.size > MAX_UPLOAD_BYTES) {
|
||||
setMessage(`File too large (max ${MAX_UPLOAD_BYTES} bytes).`);
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
try {
|
||||
const buffer = await selectedUpload.arrayBuffer();
|
||||
const base64 = bytesToBase64(new Uint8Array(buffer));
|
||||
await playUploadedAudio?.({
|
||||
roverId,
|
||||
name: selectedUpload.name,
|
||||
mime: selectedUpload.type || '',
|
||||
dataBase64: base64,
|
||||
});
|
||||
setMessage(`Playing upload on ${roverId}.`);
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Failed to play upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadStop = async () => {
|
||||
const roverId = targetRoverId;
|
||||
if (!roverId) {
|
||||
setMessage('Take control of a rover first.');
|
||||
return;
|
||||
}
|
||||
setWorking(true);
|
||||
setMessage('');
|
||||
try {
|
||||
await stopUploadedAudio?.(roverId);
|
||||
setMessage(`Stopped upload on ${roverId}.`);
|
||||
} catch (err) {
|
||||
setMessage(err.message || 'Failed to stop upload.');
|
||||
} finally {
|
||||
setWorking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const stopMicCapture = useCallback(
|
||||
async (roverId) => {
|
||||
const target = String(roverId || activeRoverRef.current || '').trim();
|
||||
micActiveRef.current = false;
|
||||
setMicState('idle');
|
||||
try {
|
||||
if (processorRef.current && mediaSourceRef.current) {
|
||||
mediaSourceRef.current.disconnect(processorRef.current);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
if (processorRef.current && sinkRef.current) {
|
||||
processorRef.current.disconnect(sinkRef.current);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
if (sinkRef.current && audioContextRef.current?.destination) {
|
||||
sinkRef.current.disconnect(audioContextRef.current.destination);
|
||||
}
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
if (audioContextRef.current) {
|
||||
try {
|
||||
await audioContextRef.current.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
if (whipPcRef.current) {
|
||||
try {
|
||||
whipPcRef.current.getSenders().forEach((sender) => sender.track?.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
whipPcRef.current.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
whipPcRef.current = null;
|
||||
processorRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
sinkRef.current = null;
|
||||
audioContextRef.current = null;
|
||||
pendingPcmChunksRef.current = [];
|
||||
pendingPcmBytesRef.current = 0;
|
||||
whipFailoverRef.current = false;
|
||||
micTransportRef.current = 'none';
|
||||
if (streamRef.current) {
|
||||
try {
|
||||
streamRef.current.getTracks().forEach((track) => track.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
streamRef.current = null;
|
||||
if (target) {
|
||||
try {
|
||||
await stopMicWhip?.(target);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
try {
|
||||
await stopMicForward?.(target);
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
activeRoverRef.current = '';
|
||||
},
|
||||
[stopMicForward, stopMicWhip],
|
||||
);
|
||||
|
||||
const startSocketBridge = useCallback(
|
||||
async (target, stream) => {
|
||||
const AudioContextCtor = window.AudioContext || window.webkitAudioContext;
|
||||
if (!AudioContextCtor) {
|
||||
throw new Error('Web Audio API is not supported in this browser.');
|
||||
}
|
||||
await startMicForward?.(target);
|
||||
const audioContext = new AudioContextCtor({ latencyHint: 'interactive' });
|
||||
audioContextRef.current = audioContext;
|
||||
const source = audioContext.createMediaStreamSource(stream);
|
||||
mediaSourceRef.current = source;
|
||||
const processor = audioContext.createScriptProcessor(1024, 1, 1);
|
||||
processorRef.current = processor;
|
||||
const sink = audioContext.createGain();
|
||||
sink.gain.value = 0;
|
||||
sinkRef.current = sink;
|
||||
pendingPcmChunksRef.current = [];
|
||||
pendingPcmBytesRef.current = 0;
|
||||
micTransportRef.current = 'socket';
|
||||
|
||||
processor.onaudioprocess = (event) => {
|
||||
if (!micActiveRef.current || micTransportRef.current !== 'socket') return;
|
||||
const input = event.inputBuffer?.getChannelData(0);
|
||||
if (!input || input.length === 0) return;
|
||||
const resampled = resampleTo16k(input, audioContext.sampleRate);
|
||||
if (!resampled.length) return;
|
||||
const pcmBytes = floatToInt16Bytes(resampled);
|
||||
pendingPcmChunksRef.current.push(pcmBytes);
|
||||
pendingPcmBytesRef.current += pcmBytes.length;
|
||||
|
||||
while (pendingPcmBytesRef.current >= MIC_PACKET_BYTES) {
|
||||
const merged = concatUint8(pendingPcmChunksRef.current, pendingPcmBytesRef.current);
|
||||
const packet = merged.slice(0, MIC_PACKET_BYTES);
|
||||
const rest = merged.slice(MIC_PACKET_BYTES);
|
||||
pendingPcmChunksRef.current = rest.length ? [rest] : [];
|
||||
pendingPcmBytesRef.current = rest.length;
|
||||
sendMicChunk?.({ roverId: target, data: packet });
|
||||
}
|
||||
};
|
||||
|
||||
source.connect(processor);
|
||||
processor.connect(sink);
|
||||
sink.connect(audioContext.destination);
|
||||
if (audioContext.state === 'suspended') {
|
||||
await audioContext.resume();
|
||||
}
|
||||
setMicState('live');
|
||||
},
|
||||
[sendMicChunk, startMicForward],
|
||||
);
|
||||
|
||||
const startWhipBridge = useCallback(
|
||||
async (target, stream) => {
|
||||
const startPayload = await startMicWhip?.(target);
|
||||
const whipUrl = String(startPayload?.whipUrl || '').trim();
|
||||
const token = String(startPayload?.token || '').trim();
|
||||
if (!whipUrl || !token) {
|
||||
throw new Error('WHIP endpoint unavailable');
|
||||
}
|
||||
const pc = new RTCPeerConnection(RTC_CONFIG);
|
||||
whipPcRef.current = pc;
|
||||
micTransportRef.current = 'whip';
|
||||
try {
|
||||
const senders = stream.getAudioTracks().map((track) => pc.addTrack(track, stream));
|
||||
await Promise.all(senders.map((sender) => configureSenderForLowLatency(sender)));
|
||||
pc.onconnectionstatechange = () => {
|
||||
const state = pc.connectionState;
|
||||
if (!micActiveRef.current) return;
|
||||
if (state === 'connected') {
|
||||
setMicState('live');
|
||||
return;
|
||||
}
|
||||
if (state === 'failed' || state === 'disconnected') {
|
||||
setMicState('error');
|
||||
setMessage(`WHIP transport ${state}.`);
|
||||
}
|
||||
};
|
||||
|
||||
const offer = await pc.createOffer({ offerToReceiveAudio: false, offerToReceiveVideo: false });
|
||||
await pc.setLocalDescription(offer);
|
||||
await waitForIceGatheringComplete(pc, 1800);
|
||||
|
||||
const response = await fetch(whipUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/sdp',
|
||||
...buildAuthHeader(token),
|
||||
},
|
||||
body: pc.localDescription?.sdp || offer.sdp,
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new Error(`WHIP request failed: ${response.status}`);
|
||||
}
|
||||
const answerSdp = await response.text();
|
||||
await pc.setRemoteDescription({ type: 'answer', sdp: answerSdp });
|
||||
await waitForPeerConnected(pc, 10000);
|
||||
await waitForOutboundAudioFlow(pc, 6000);
|
||||
await readyMicWhip?.(target);
|
||||
setMicState('live');
|
||||
} catch (err) {
|
||||
try {
|
||||
pc.close();
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
if (whipPcRef.current === pc) {
|
||||
whipPcRef.current = null;
|
||||
}
|
||||
micTransportRef.current = 'none';
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[readyMicWhip, startMicWhip],
|
||||
);
|
||||
|
||||
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.');
|
||||
}
|
||||
await stopMicCapture(target);
|
||||
setMicState('starting');
|
||||
let stream = null;
|
||||
try {
|
||||
stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: {
|
||||
channelCount: 1,
|
||||
sampleRate: TARGET_SAMPLE_RATE,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
},
|
||||
});
|
||||
const [track] = stream.getAudioTracks();
|
||||
if (track?.applyConstraints) {
|
||||
try {
|
||||
await track.applyConstraints({
|
||||
channelCount: 1,
|
||||
sampleRate: TARGET_SAMPLE_RATE,
|
||||
echoCancellation: false,
|
||||
noiseSuppression: false,
|
||||
autoGainControl: false,
|
||||
});
|
||||
} catch {
|
||||
// Constraint support varies by browser; use acquired track as-is.
|
||||
}
|
||||
}
|
||||
streamRef.current = stream;
|
||||
micActiveRef.current = true;
|
||||
activeRoverRef.current = target;
|
||||
await startWhipBridge(target, stream);
|
||||
} catch (err) {
|
||||
if (stream) {
|
||||
try {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
} catch {
|
||||
// noop
|
||||
}
|
||||
}
|
||||
streamRef.current = null;
|
||||
audioContextRef.current = null;
|
||||
mediaSourceRef.current = null;
|
||||
processorRef.current = null;
|
||||
sinkRef.current = null;
|
||||
whipPcRef.current = null;
|
||||
micTransportRef.current = 'none';
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
[startWhipBridge, 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}>
|
||||
<p className="text-sm text-slate-300">VIP Audio Forwarding</p>
|
||||
<label className="grid w-full gap-0.5 text-xs text-slate-300">
|
||||
<span>Audio file (mp3 / wav / ogg)</span>
|
||||
<input
|
||||
className={fieldClass}
|
||||
type="file"
|
||||
accept=".mp3,.wav,.ogg,audio/mpeg,audio/wav,audio/ogg"
|
||||
disabled={working || !targetRoverId}
|
||||
onChange={(event) => setSelectedUpload(event.target.files?.[0] || null)}
|
||||
/>
|
||||
</label>
|
||||
{selectedUpload ? (
|
||||
<div className="surface-muted mx-auto w-full max-w-sm text-xs text-slate-300 text-center">
|
||||
{selectedUpload.name} ({selectedUpload.size} bytes)
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex justify-center gap-0.5">
|
||||
<button type="button" className="button-dark text-sm" disabled={working || !targetRoverId} onClick={handleUploadPlay}>
|
||||
{working ? 'Working...' : 'Play Upload'}
|
||||
</button>
|
||||
<button type="button" className="button-dark text-sm" disabled={working || !targetRoverId} onClick={handleUploadStop}>
|
||||
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>
|
||||
<p className="text-slate-500">transport: {micTransportRef.current === 'none' ? 'idle' : micTransportRef.current}</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'}
|
||||
{selectedForwardState.error ? ` | error: ${selectedForwardState.error}` : ''}
|
||||
</div>
|
||||
) : null}
|
||||
{message ? <div className="text-xs text-slate-400 text-center">{message}</div> : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,4 @@
|
||||
export const COOKIE_KEY_REGEX = /^cu_[a-f0-9]{32}$/;
|
||||
export const MAX_UPLOAD_BYTES = 8 * 1024 * 1024;
|
||||
|
||||
export const fieldClass = 'field-input w-full max-w-sm text-left focus:ring-emerald-500';
|
||||
export const flowWrapClass = 'mx-auto w-full max-w-xl flex justify-center';
|
||||
@@ -11,13 +10,3 @@ export function maskKey(value) {
|
||||
if (key.length <= 10) return `${key.slice(0, 2)}***${key.slice(-2)}`;
|
||||
return `${key.slice(0, 6)}...${key.slice(-6)}`;
|
||||
}
|
||||
|
||||
export function bytesToBase64(bytes) {
|
||||
let binary = '';
|
||||
const chunkSize = 0x8000;
|
||||
for (let i = 0; i < bytes.length; i += chunkSize) {
|
||||
const chunk = bytes.subarray(i, i + chunkSize);
|
||||
binary += String.fromCharCode(...chunk);
|
||||
}
|
||||
return btoa(binary);
|
||||
}
|
||||
|
||||
@@ -26,14 +26,6 @@ const SessionContext = createContext({
|
||||
setAdminReason: async () => {},
|
||||
rebootRover: async () => {},
|
||||
rebootServer: async () => {},
|
||||
playUploadedAudio: async () => {},
|
||||
stopUploadedAudio: async () => {},
|
||||
startMicForward: async () => {},
|
||||
stopMicForward: async () => {},
|
||||
sendMicChunk: async () => {},
|
||||
startMicWhip: async () => {},
|
||||
readyMicWhip: async () => {},
|
||||
stopMicWhip: async () => {},
|
||||
setAudioLevels: async () => {},
|
||||
llmControl: async () => {},
|
||||
});
|
||||
@@ -150,23 +142,6 @@ export function SessionProvider({ children }) {
|
||||
rebootRover: (roverId) =>
|
||||
emitWithAck('command', { roverId, type: 'reboot', data: { reboot: {} } }),
|
||||
rebootServer: () => emitWithAck('server:reboot'),
|
||||
playUploadedAudio: ({ roverId, name, mime, dataBase64 }) =>
|
||||
emitWithAck('audio:uploadPlay', { roverId, name, mime, dataBase64 }),
|
||||
stopUploadedAudio: (roverId) => emitWithAck('audio:uploadStop', { roverId }),
|
||||
startMicForward: (roverId) => emitWithAck('audio:micStart', { roverId }),
|
||||
stopMicForward: (roverId) => emitWithAck('audio:micStop', { roverId }),
|
||||
sendMicChunk: ({ roverId, dataBase64, data }) => {
|
||||
if (!socket.connected) return false;
|
||||
const ws = socket.io?.engine?.transport?.ws;
|
||||
if (ws && typeof ws.bufferedAmount === 'number' && ws.bufferedAmount > 256 * 1024) {
|
||||
return false;
|
||||
}
|
||||
socket.emit('audio:micChunk', { roverId, dataBase64, data });
|
||||
return true;
|
||||
},
|
||||
startMicWhip: (roverId) => emitWithAck('audio:micWhipStart', { roverId }),
|
||||
readyMicWhip: (roverId) => emitWithAck('audio:micWhipReady', { roverId }),
|
||||
stopMicWhip: (roverId) => emitWithAck('audio:micWhipStop', { roverId }),
|
||||
setAudioLevels: (levels = {}) => emitWithAck('audioLevels:set', levels),
|
||||
llmControl: (action, controls = {}) =>
|
||||
emitWithAck('llm:control', { controls: { action, ...controls } }),
|
||||
@@ -176,7 +151,7 @@ export function SessionProvider({ children }) {
|
||||
{ ...alert, receivedAt: Date.now(), id: alert.id || Math.random().toString(36).slice(2) },
|
||||
]),
|
||||
}),
|
||||
[emitWithAck, socket],
|
||||
[emitWithAck],
|
||||
);
|
||||
|
||||
const value = useMemo(
|
||||
|
||||
@@ -452,10 +452,6 @@ export function ControlSystemProvider({ children }) {
|
||||
dispatch({ type: 'control/register-input-state', payload: { source, state: data } });
|
||||
}, []);
|
||||
|
||||
const setMicPttActive = useCallback((active) => {
|
||||
dispatch({ type: 'control/set-mic-ptt', payload: Boolean(active) });
|
||||
}, []);
|
||||
|
||||
const contextValue = useMemo(
|
||||
() => ({
|
||||
state,
|
||||
@@ -482,7 +478,6 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
},
|
||||
}),
|
||||
[
|
||||
@@ -508,7 +503,6 @@ export function ControlSystemProvider({ children }) {
|
||||
sendSong,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
],
|
||||
);
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const DEFAULT_KEYMAP = {
|
||||
cameraDown: ['j'],
|
||||
nightVisionToggle: ['e'],
|
||||
hornHonk: ['h'],
|
||||
micPtt: ['m'],
|
||||
driveMacro: ['f'],
|
||||
dockMacro: ['g'],
|
||||
chatFocus: ['enter'],
|
||||
|
||||
@@ -35,12 +35,6 @@ function createHornState() {
|
||||
};
|
||||
}
|
||||
|
||||
function createMicState() {
|
||||
return {
|
||||
pttActive: false,
|
||||
};
|
||||
}
|
||||
|
||||
export const initialControlState = {
|
||||
roverId: null,
|
||||
mode: 'drive',
|
||||
@@ -49,7 +43,6 @@ export const initialControlState = {
|
||||
camera: createCameraState(),
|
||||
song: createSongState(),
|
||||
horn: createHornState(),
|
||||
mic: createMicState(),
|
||||
lastControlIntentAt: 0,
|
||||
macros: DEFAULT_MACROS,
|
||||
keymap: DEFAULT_KEYMAP,
|
||||
@@ -66,7 +59,6 @@ export function controlReducer(state, action) {
|
||||
aux: action.payload ? state.aux : createAuxState(),
|
||||
song: action.payload ? state.song : createSongState(),
|
||||
horn: action.payload ? state.horn : createHornState(),
|
||||
mic: action.payload ? state.mic : createMicState(),
|
||||
lastControlIntentAt: action.payload ? state.lastControlIntentAt : 0,
|
||||
};
|
||||
case 'control/set-mode':
|
||||
@@ -144,7 +136,6 @@ export function controlReducer(state, action) {
|
||||
aux: createAuxState(),
|
||||
song: createSongState(),
|
||||
horn: createHornState(),
|
||||
mic: createMicState(),
|
||||
lastControlIntentAt: 0,
|
||||
};
|
||||
case 'control/set-horn-active':
|
||||
@@ -177,14 +168,6 @@ export function controlReducer(state, action) {
|
||||
note: action.payload ?? SONG_DEFAULT_NOTE,
|
||||
},
|
||||
};
|
||||
case 'control/set-mic-ptt':
|
||||
return {
|
||||
...state,
|
||||
mic: {
|
||||
...(state.mic || createMicState()),
|
||||
pttActive: Boolean(action.payload),
|
||||
},
|
||||
};
|
||||
default:
|
||||
return state;
|
||||
}
|
||||
|
||||
@@ -129,7 +129,6 @@ export default function KeyboardInputManager() {
|
||||
toggleNightVision,
|
||||
startHorn,
|
||||
stopHorn,
|
||||
setMicPttActive,
|
||||
setSongNote,
|
||||
sendSong,
|
||||
},
|
||||
@@ -308,10 +307,9 @@ export default function KeyboardInputManager() {
|
||||
hornActiveRef.current = false;
|
||||
stopHorn();
|
||||
}
|
||||
setMicPttActive(false);
|
||||
stopAllMotion();
|
||||
registerInputState(SOURCE, { keys: [], vector: ZERO_VECTOR, aux: ZERO_AUX });
|
||||
}, [registerInputState, setMicPttActive, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
|
||||
}, [registerInputState, stopAllMotion, stopHorn, stopServoLoop, stopSongLoop]);
|
||||
|
||||
const triggerHomeAssistantCycle = useCallback(
|
||||
(targetState) => {
|
||||
@@ -380,8 +378,6 @@ export default function KeyboardInputManager() {
|
||||
const started = startHorn();
|
||||
hornActiveRef.current = Boolean(started);
|
||||
}
|
||||
} else if (newlyPressed.some((token) => keymap.micPtt?.has(token))) {
|
||||
setMicPttActive(true);
|
||||
} else if (newlyPressed.some((token) => keymap.homeAssistantOn?.has(token))) {
|
||||
triggerHomeAssistantCycle('on');
|
||||
} else if (newlyPressed.some((token) => keymap.homeAssistantOff?.has(token))) {
|
||||
@@ -402,9 +398,6 @@ export default function KeyboardInputManager() {
|
||||
hornActiveRef.current = false;
|
||||
stopHorn();
|
||||
}
|
||||
if (!bindingActive(keymap.micPtt, activeTokensRef.current)) {
|
||||
setMicPttActive(false);
|
||||
}
|
||||
ensureServoLoop();
|
||||
ensureSongLoop();
|
||||
driveFromKeys();
|
||||
@@ -434,10 +427,8 @@ export default function KeyboardInputManager() {
|
||||
keymap.dockMacro,
|
||||
keymap.driveMacro,
|
||||
keymap.hornHonk,
|
||||
keymap.micPtt,
|
||||
resetAll,
|
||||
runMacro,
|
||||
setMicPttActive,
|
||||
setMode,
|
||||
stopAllMotion,
|
||||
stopSongLoop,
|
||||
|
||||
Reference in New Issue
Block a user