telepresence / src /components /AudioControlCard.tsx
tfrere's picture
tfrere HF Staff
chore(deps): upgrade MUI to v9.0.1
166dc1c
/**
* Audio control card.
*
* ┌──────────────────────────────────────────────────────┐
* │ ┌──────────────────┐ ┌──────────────────┐ │
* │ │ ▌▎▍▎▍▌▎▍▎▌▎▍▎▌▎ │ │ ▌▎▍▎▍▌▎▍▎▌▎▍▎▌▎ │ │
* │ └──────────────────┘ └──────────────────┘ │
* │ ┌──────────────────┐ ┌──────────────────┐ │
* │ │ 🎤 Mic on │ │ 🔊 Sound on │ │
* │ └──────────────────┘ └──────────────────┘ │
* │ 🎙 Mic gain 80% 🔊 Speaker 100% │
* │ ──●───────────── ───────●─────── │
* └──────────────────────────────────────────────────────┘
*
* Two parallel columns - one per direction of audio. Each column
* is self-contained top-to-bottom:
*
* - LEFT ("Mic"): spectrum viz of your mic + toggle + mic-gain slider
* - RIGHT ("Sound"): spectrum viz of robot audio + toggle + speaker slider
*
* Stacking the viz on top of the toggle (rather than a single
* bidirectional header) makes ownership obvious: every visual
* element ABOVE a column is driven by that column's stream.
*/
import {
Alert,
Box,
Paper,
Slider,
Stack,
ToggleButton,
Typography,
useTheme,
} from '@mui/material';
import type { ReactNode } from 'react';
import MicIcon from '@mui/icons-material/Mic';
import MicOffIcon from '@mui/icons-material/MicOff';
import VolumeUpIcon from '@mui/icons-material/VolumeUp';
import VolumeOffIcon from '@mui/icons-material/VolumeOff';
import GraphicEqIcon from '@mui/icons-material/GraphicEq';
import { useEffect, useState } from 'react';
import type { ReachyMiniInstance } from '@/sdk-types';
import ChannelSpectrum from './ChannelSpectrum';
interface AudioControlCardProps {
robot: ReachyMiniInstance;
micMuted: boolean;
audioMuted: boolean;
/** Local mic MediaStream (user → robot). Powers the top spectrum half. */
micStream: MediaStream | null;
/** Remote MediaStream containing the robot's audio track
* (robot → user). Powers the bottom spectrum half. */
robotStream: MediaStream | null;
onSetMicMuted(muted: boolean): void;
onSetAudioMuted(muted: boolean): void;
}
export default function AudioControlCard({
robot,
micMuted,
audioMuted,
micStream,
robotStream,
onSetMicMuted,
onSetAudioMuted,
}: AudioControlCardProps) {
const theme = useTheme();
const [speakerVolume, setSpeakerVolume] = useState<number | null>(null);
const [micVolume, setMicVolume] = useState<number | null>(null);
const [pendingSpeaker, setPendingSpeaker] = useState(false);
const [pendingMic, setPendingMic] = useState(false);
// Pull the daemon's current volumes when the card mounts. The
// round-trip can take a few hundred ms if the data channel is
// freshly negotiated, so we render `null` (slider in indeterminate
// state) until the values land.
useEffect(() => {
let cancelled = false;
void (async () => {
try {
const [spk, mic] = await Promise.all([
robot.getVolume(),
robot.getMicrophoneVolume(),
]);
if (cancelled) return;
if (spk !== null) setSpeakerVolume(spk);
if (mic !== null) setMicVolume(mic);
} catch (err) {
console.warn('[audio] could not read volumes:', err);
}
})();
return () => {
cancelled = true;
};
}, [robot]);
const applySpeakerVolume = async (value: number) => {
setSpeakerVolume(value);
setPendingSpeaker(true);
try {
const acked = await robot.setVolume(value);
if (acked !== null) setSpeakerVolume(acked);
} catch (err) {
console.warn('[audio] setVolume failed:', err);
} finally {
setPendingSpeaker(false);
}
};
const applyMicVolume = async (value: number) => {
setMicVolume(value);
setPendingMic(true);
try {
const acked = await robot.setMicrophoneVolume(value);
if (acked !== null) setMicVolume(acked);
} catch (err) {
console.warn('[audio] setMicrophoneVolume failed:', err);
} finally {
setPendingMic(false);
}
};
const micSupported = robot.micSupported;
return (
<Paper
elevation={0}
sx={{
p: 1.75,
display: 'flex',
flexDirection: 'column',
gap: 1.5,
// Match the mobile app's `RADIUS.lg` (12 px = shape.borderRadius).
// Less pronounced than the previous `3` (36 px) which read as
// "blob". Lighter cards = quieter UI.
borderRadius: 1,
// 1 px hairline border instead of a heavy shadow. Matches the
// mobile app's "paper-on-canvas" aesthetic - we want the card
// to read as a slight elevation, not a floating drawer.
border: `1px solid ${theme.palette.divider}`,
boxShadow: 'none',
backgroundColor: theme.palette.background.paper,
}}
>
{!micSupported && (
<Alert
severity="info"
icon={false}
sx={{
py: 0.25,
px: 1,
fontSize: 11,
lineHeight: 1.4,
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255, 149, 0, 0.08)'
: 'rgba(255, 149, 0, 0.12)',
color: theme.palette.text.primary,
border: 'none',
borderRadius: 1,
}}
>
The robot reports no microphone support. You can hear it but
your voice won't be sent.
</Alert>
)}
<Stack direction="row" spacing={1.5}>
<ChannelColumn
channelLabel="Mic"
isOn={!micMuted && micSupported}
onIcon={<MicIcon fontSize="small" />}
offIcon={<MicOffIcon fontSize="small" />}
disabled={!micSupported}
onToggle={() => onSetMicMuted(!micMuted)}
stream={micStream}
vizActive={!micMuted && micSupported && micStream != null}
sliderIcon={<GraphicEqIcon sx={{ fontSize: 14 }} />}
sliderLabel="Mic gain"
sliderValue={micVolume}
sliderPending={pendingMic}
onSliderChange={(v) => setMicVolume(v)}
onSliderCommit={(v) => void applyMicVolume(v)}
/>
<ChannelColumn
channelLabel="Sound"
isOn={!audioMuted}
onIcon={<VolumeUpIcon fontSize="small" />}
offIcon={<VolumeOffIcon fontSize="small" />}
onToggle={() => onSetAudioMuted(!audioMuted)}
stream={robotStream}
vizActive={!audioMuted && robotStream != null}
sliderIcon={<VolumeUpIcon sx={{ fontSize: 14 }} />}
sliderLabel="Speaker"
sliderValue={speakerVolume}
sliderPending={pendingSpeaker}
onSliderChange={(v) => setSpeakerVolume(v)}
onSliderCommit={(v) => void applySpeakerVolume(v)}
/>
</Stack>
</Paper>
);
}
interface ChannelColumnProps {
/** Top toggle button label ("Mic" → "Mic on" / "Mic off"). */
channelLabel: string;
/** Toggle button state - is the channel currently active? */
isOn: boolean;
/** Disable the toggle. Used when daemon reports no mic support. */
disabled?: boolean;
/** Icon shown when `isOn` is true. */
onIcon: ReactNode;
/** Icon shown when `isOn` is false. */
offIcon: ReactNode;
onToggle(): void;
/** MediaStream feeding the spectrum viz. */
stream: MediaStream | null;
/** When `false`, the viz dims out (channel is off). */
vizActive: boolean;
/** Slider row icon. */
sliderIcon: ReactNode;
/** Slider row label. */
sliderLabel: string;
/** Current slider value (null = round-tripping with the daemon). */
sliderValue: number | null;
/** Slider is awaiting a daemon ack (dims the % readout). */
sliderPending: boolean;
onSliderChange(value: number): void;
onSliderCommit(value: number): void;
}
/**
* One channel column. Three vertically-aligned elements:
*
* ┌───────────────────────┐
* │ ▌ ▎ ▍ ▎ ▍ ▌ ▎ ▍ ▎ │ ← live spectrum viz
* ├───────────────────────┤
* │ [icon] Mic on │ ← toggle (primary tint when ON)
* ├───────────────────────┤
* │ 🎙 Mic gain 80% │ ← slider header
* │ ──●──────────────── │ ← slider track
* └───────────────────────┘
*
* The viz lives INSIDE the column it represents (one viz per
* channel, side-by-side), making the ownership obvious without
* labelling: bars above the "Mic" toggle = mic; bars above
* "Sound" toggle = robot speaker.
*/
function ChannelColumn({
channelLabel,
isOn,
disabled = false,
onIcon,
offIcon,
onToggle,
stream,
vizActive,
sliderIcon,
sliderLabel,
sliderValue,
sliderPending,
onSliderChange,
onSliderCommit,
}: ChannelColumnProps) {
const theme = useTheme();
return (
<Box
sx={{
flex: 1,
minWidth: 0,
display: 'flex',
flexDirection: 'column',
gap: 1,
}}
>
<ChannelSpectrum stream={stream} active={vizActive} />
<ToggleButton
value="channel"
selected={isOn}
onChange={onToggle}
disabled={disabled}
sx={{
gap: 1,
py: 1,
px: 1.25,
// Soft-cornered button - same radius as the card. Avoids
// the "pill button inside a square card" mismatch.
borderRadius: 1,
border: 'none',
textTransform: 'none',
color: 'text.secondary',
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255,255,255,0.04)'
: 'rgba(0,0,0,0.035)',
transition: theme.transitions.create(
['background-color', 'color'],
{ duration: theme.transitions.duration.shortest },
),
'&:hover': {
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255,255,255,0.07)'
: 'rgba(0,0,0,0.06)',
},
'&.Mui-selected': {
color: 'primary.main',
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255, 149, 0, 0.14)'
: 'rgba(255, 149, 0, 0.1)',
'&:hover': {
backgroundColor:
theme.palette.mode === 'dark'
? 'rgba(255, 149, 0, 0.2)'
: 'rgba(255, 149, 0, 0.16)',
},
},
}}
>
{isOn ? onIcon : offIcon}
<Typography
variant="caption"
sx={{ fontWeight: 600, fontSize: 12, letterSpacing: '0.15px' }}
>
{channelLabel} {isOn ? 'on' : 'off'}
</Typography>
</ToggleButton>
<Box>
<Stack
direction="row"
spacing={1}
sx={{
alignItems: "center",
justifyContent: "space-between",
mb: 0.25
}}>
<Stack direction="row" spacing={0.6} sx={{
alignItems: "center"
}}>
<Box
sx={{
display: 'flex',
alignItems: 'center',
color: 'text.secondary',
}}
>
{sliderIcon}
</Box>
<Typography
variant="caption"
sx={{ color: 'text.secondary', fontSize: 11.5 }}
>
{sliderLabel}
</Typography>
</Stack>
<Typography
variant="caption"
sx={{
color: 'text.secondary',
fontFamily: 'monospace',
fontSize: 11,
opacity: sliderPending ? 0.5 : 1,
}}
>
{sliderValue === null ? '— %' : `${sliderValue}%`}
</Typography>
</Stack>
<Slider
aria-label={sliderLabel}
value={sliderValue ?? 0}
min={0}
max={100}
disabled={sliderValue === null}
onChange={(_, v) => {
if (typeof v === 'number') onSliderChange(v);
}}
onChangeCommitted={(_, v) => {
if (typeof v === 'number') onSliderCommit(v);
}}
size="small"
sx={{
'& .MuiSlider-thumb': {
width: 12,
height: 12,
// Very subtle thumb shadow - we want it to read as
// "indicator" not "physical knob".
boxShadow: '0 1px 2px rgba(0,0,0,0.18)',
},
'& .MuiSlider-rail': { opacity: 0.35 },
}}
/>
</Box>
</Box>
);
}