File size: 13,505 Bytes
f7a64d7
19ee3d7
f7a64d7
6c1195d
4d368d2
 
 
 
 
 
 
 
6c1195d
f7a64d7
4d368d2
 
6e3f703
4d368d2
 
 
 
 
 
f7a64d7
 
 
 
 
 
 
 
 
 
 
6c1195d
f7a64d7
 
 
 
 
 
 
 
4d368d2
f7a64d7
 
 
 
 
19ee3d7
c86ac21
 
19ee3d7
c86ac21
f7a64d7
 
 
 
 
 
 
 
c86ac21
 
f7a64d7
 
 
6c1195d
 
f7a64d7
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
19ee3d7
f7a64d7
 
19ee3d7
 
 
 
 
 
 
 
 
 
6c1195d
f7a64d7
 
 
 
 
 
 
6e3f703
f7a64d7
6e3f703
 
f7a64d7
 
 
 
 
6c1195d
19ee3d7
f7a64d7
 
6e3f703
 
f7a64d7
 
 
19ee3d7
6c1195d
 
 
 
 
 
 
4d368d2
 
6c1195d
 
 
 
 
 
f7a64d7
6c1195d
 
 
 
 
 
4d368d2
 
6c1195d
 
 
 
 
 
f7a64d7
6e3f703
f7a64d7
 
 
6e3f703
6c1195d
 
 
 
 
 
 
 
 
 
 
 
4d368d2
 
 
 
6c1195d
 
 
 
 
 
 
 
 
 
 
 
6e3f703
4d368d2
6c1195d
 
4d368d2
 
19ee3d7
6c1195d
 
 
 
6e3f703
4d368d2
 
 
 
6e3f703
6c1195d
 
 
 
 
 
 
4d368d2
 
6c1195d
 
 
 
 
 
 
 
6e3f703
19ee3d7
 
 
 
 
 
 
 
 
4d368d2
6c1195d
 
 
 
 
 
 
19ee3d7
 
 
 
 
6c1195d
 
 
 
 
 
19ee3d7
6c1195d
19ee3d7
6c1195d
 
 
 
 
19ee3d7
6c1195d
 
 
 
 
 
19ee3d7
 
6c1195d
 
 
19ee3d7
 
6c1195d
 
 
6e3f703
6c1195d
 
 
19ee3d7
6c1195d
 
 
 
 
 
 
 
166dc1c
 
 
 
 
 
 
 
6c1195d
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
9759faa
 
6c1195d
 
 
 
 
 
9759faa
6c1195d
9759faa
 
6c1195d
 
 
 
 
 
 
 
 
 
 
 
 
9759faa
6c1195d
19ee3d7
 
 
 
 
6c1195d
19ee3d7
9759faa
6c1195d
 
9759faa
6e3f703
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
/**
 * 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>
  );
}