Spaces:
Running
Running
| /** | |
| * 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> | |
| ); | |
| } | |