/** * 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(null); const [micVolume, setMicVolume] = useState(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 ( {!micSupported && ( The robot reports no microphone support. You can hear it but your voice won't be sent. )} } offIcon={} disabled={!micSupported} onToggle={() => onSetMicMuted(!micMuted)} stream={micStream} vizActive={!micMuted && micSupported && micStream != null} sliderIcon={} sliderLabel="Mic gain" sliderValue={micVolume} sliderPending={pendingMic} onSliderChange={(v) => setMicVolume(v)} onSliderCommit={(v) => void applyMicVolume(v)} /> } offIcon={} onToggle={() => onSetAudioMuted(!audioMuted)} stream={robotStream} vizActive={!audioMuted && robotStream != null} sliderIcon={} sliderLabel="Speaker" sliderValue={speakerVolume} sliderPending={pendingSpeaker} onSliderChange={(v) => setSpeakerVolume(v)} onSliderCommit={(v) => void applySpeakerVolume(v)} /> ); } 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 ( {isOn ? onIcon : offIcon} {channelLabel} {isOn ? 'on' : 'off'} {sliderIcon} {sliderLabel} {sliderValue === null ? '— %' : `${sliderValue}%`} { 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 }, }} /> ); }