HomePilot Deploy Bot commited on
Commit
fd13f31
·
1 Parent(s): cec8735

chore(hf): sync HomePilot to HF Space

Browse files
.gitattributes CHANGED
@@ -1,9 +1,9 @@
1
  *.hpersona filter=lfs diff=lfs merge=lfs -text
 
 
2
  *.png filter=lfs diff=lfs merge=lfs -text
3
  *.jpg filter=lfs diff=lfs merge=lfs -text
4
  *.webp filter=lfs diff=lfs merge=lfs -text
5
- *.svg filter=lfs diff=lfs merge=lfs -text
6
- *.woff2 filter=lfs diff=lfs merge=lfs -text
7
- *.jpeg filter=lfs diff=lfs merge=lfs -text
8
  *.woff filter=lfs diff=lfs merge=lfs -text
 
9
  *.ico filter=lfs diff=lfs merge=lfs -text
 
1
  *.hpersona filter=lfs diff=lfs merge=lfs -text
2
+ *.jpeg filter=lfs diff=lfs merge=lfs -text
3
+ *.svg filter=lfs diff=lfs merge=lfs -text
4
  *.png filter=lfs diff=lfs merge=lfs -text
5
  *.jpg filter=lfs diff=lfs merge=lfs -text
6
  *.webp filter=lfs diff=lfs merge=lfs -text
 
 
 
7
  *.woff filter=lfs diff=lfs merge=lfs -text
8
+ *.woff2 filter=lfs diff=lfs merge=lfs -text
9
  *.ico filter=lfs diff=lfs merge=lfs -text
frontend/.cache-bust CHANGED
@@ -1 +1 @@
1
- 1789232245
 
1
+ 1789232300
frontend/index.html CHANGED
@@ -18,6 +18,9 @@
18
  <!-- MS29. The recorder. Inert until `hpMeetingSense.start()` is called, so loading it
19
  costs a parse and changes nothing until somebody presses record. -->
20
  <script src="/js/homepilot-meetingsense.js"></script>
 
 
 
21
  <script type="module" src="/src/main.tsx"></script>
22
  </body>
23
  </html>
 
18
  <!-- MS29. The recorder. Inert until `hpMeetingSense.start()` is called, so loading it
19
  costs a parse and changes nothing until somebody presses record. -->
20
  <script src="/js/homepilot-meetingsense.js"></script>
21
+ <!-- Media routing owns browser device acquisition so preflight capture switches and the
22
+ Audio & Video microphone preference are authoritative for MeetingSense. -->
23
+ <script src="/js/homepilot-meetingsense-media.js"></script>
24
  <script type="module" src="/src/main.tsx"></script>
25
  </body>
26
  </html>
frontend/public/js/homepilot-meetingsense-media.js ADDED
@@ -0,0 +1,237 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /**
2
+ * MeetingSense media-routing contract.
3
+ *
4
+ * Loaded immediately after homepilot-meetingsense.js. It keeps the recorder's proven audio
5
+ * graph/socket implementation, but owns device acquisition so the meeting preflight and the
6
+ * Audio & Video settings are actually authoritative.
7
+ */
8
+ (function () {
9
+ 'use strict';
10
+
11
+ var recorder = window.hpMeetingSense;
12
+ if (!recorder || recorder.__homepilotMediaRoutingV1) return;
13
+
14
+ var STORAGE_KEY = 'homepilot_media_preferences_v1';
15
+ var originalStartWithStreams =
16
+ typeof recorder.startWithStreams === 'function' ? recorder.startWithStreams.bind(recorder) : null;
17
+ var originalMediaClock =
18
+ typeof recorder._mediaClockMs === 'function' ? recorder._mediaClockMs.bind(recorder) : null;
19
+
20
+ function stopStream(stream) {
21
+ if (!stream || !stream.getTracks) return;
22
+ stream.getTracks().forEach(function (track) {
23
+ try { track.stop(); } catch (_) { /* already stopped */ }
24
+ });
25
+ }
26
+
27
+ function readPreferences() {
28
+ var raw = {};
29
+ try {
30
+ raw = JSON.parse(window.localStorage.getItem(STORAGE_KEY) || '{}') || {};
31
+ } catch (_) {
32
+ raw = {};
33
+ }
34
+ return {
35
+ microphoneDeviceId: typeof raw.microphoneDeviceId === 'string' ? raw.microphoneDeviceId : '',
36
+ echoCancellation: typeof raw.echoCancellation === 'boolean' ? raw.echoCancellation : true,
37
+ noiseSuppression: typeof raw.noiseSuppression === 'boolean' ? raw.noiseSuppression : true,
38
+ autoGainControl: typeof raw.autoGainControl === 'boolean' ? raw.autoGainControl : true,
39
+ };
40
+ }
41
+
42
+ function capturePlan(options) {
43
+ var opts = options || {};
44
+ return {
45
+ meetingAudio: opts.audio !== false,
46
+ microphone: opts.mic !== false,
47
+ screen: !!opts.watch,
48
+ };
49
+ }
50
+
51
+ function microphoneConstraints(options) {
52
+ var opts = options || {};
53
+ var saved = readPreferences();
54
+ var constraints = {
55
+ echoCancellation:
56
+ typeof opts.echoCancellation === 'boolean' ? opts.echoCancellation : saved.echoCancellation,
57
+ noiseSuppression:
58
+ typeof opts.noiseSuppression === 'boolean' ? opts.noiseSuppression : saved.noiseSuppression,
59
+ autoGainControl:
60
+ typeof opts.autoGainControl === 'boolean' ? opts.autoGainControl : saved.autoGainControl,
61
+ };
62
+ var deviceId =
63
+ typeof opts.microphoneDeviceId === 'string' ? opts.microphoneDeviceId : saved.microphoneDeviceId;
64
+ if (deviceId) constraints.deviceId = { exact: deviceId };
65
+ return constraints;
66
+ }
67
+
68
+ function errorText(source, error) {
69
+ var name = error && error.name ? String(error.name) : 'CaptureError';
70
+ var message = error && error.message ? String(error.message) : '';
71
+ var suffix = message && message !== name ? ': ' + message : '';
72
+ return source + ' could not be opened (' + name + ')' + suffix;
73
+ }
74
+
75
+ function trackLabel(stream) {
76
+ try {
77
+ if (recorder.internals && typeof recorder.internals.trackLabel === 'function') {
78
+ return recorder.internals.trackLabel(stream);
79
+ }
80
+ var track = stream && stream.getVideoTracks && stream.getVideoTracks()[0];
81
+ return (track && track.label ? track.label : '').trim();
82
+ } catch (_) {
83
+ return '';
84
+ }
85
+ }
86
+
87
+ async function acquire(options) {
88
+ var opts = options || {};
89
+ var plan = capturePlan(opts);
90
+ var display = null;
91
+ var mic = null;
92
+
93
+ if ((plan.meetingAudio || plan.screen) &&
94
+ (!navigator.mediaDevices || typeof navigator.mediaDevices.getDisplayMedia !== 'function')) {
95
+ return { ok: false, error: 'Screen / meeting-audio capture is not supported by this browser.' };
96
+ }
97
+ if (plan.microphone &&
98
+ (!navigator.mediaDevices || typeof navigator.mediaDevices.getUserMedia !== 'function')) {
99
+ return { ok: false, error: 'Microphone capture is not supported by this browser.' };
100
+ }
101
+
102
+ if (plan.meetingAudio || plan.screen) {
103
+ try {
104
+ display = await navigator.mediaDevices.getDisplayMedia({
105
+ video: true,
106
+ audio: plan.meetingAudio,
107
+ });
108
+ } catch (error) {
109
+ return {
110
+ ok: false,
111
+ error: errorText(plan.meetingAudio ? 'Meeting audio / screen share' : 'Screen share', error),
112
+ };
113
+ }
114
+
115
+ if (plan.meetingAudio && !display.getAudioTracks().length) {
116
+ stopStream(display);
117
+ return {
118
+ ok: false,
119
+ error: 'Meeting audio was enabled, but the shared source has no audio track. Share a browser tab and enable “Share audio”, or turn Meeting audio off.',
120
+ };
121
+ }
122
+ }
123
+
124
+ if (plan.microphone) {
125
+ try {
126
+ mic = await navigator.mediaDevices.getUserMedia({
127
+ audio: microphoneConstraints(opts),
128
+ video: false,
129
+ });
130
+ } catch (error) {
131
+ stopStream(display);
132
+ return { ok: false, error: errorText('Selected microphone', error) };
133
+ }
134
+ }
135
+
136
+ return { ok: true, display: display, mic: mic, plan: plan };
137
+ }
138
+
139
+ async function startScreenOnly(screen, options) {
140
+ var opts = options || {};
141
+ if (!screen || !screen.getVideoTracks || !screen.getVideoTracks().length) {
142
+ stopStream(screen);
143
+ return { ok: false, error: 'Screen & slides is enabled, but no live screen track was returned.' };
144
+ }
145
+ if (typeof recorder._connect !== 'function' || typeof recorder._startWatching !== 'function') {
146
+ stopStream(screen);
147
+ return { ok: false, error: 'This MeetingSense build cannot start a screen-only meeting.' };
148
+ }
149
+
150
+ recorder._windowTitle = opts.windowTitle || trackLabel(screen);
151
+ recorder.audioMode = 'none';
152
+ recorder._channels = 1;
153
+ recorder._opts = opts;
154
+ recorder._elapsedSamples = 0;
155
+ recorder._screenOnlyStartedAt = typeof performance !== 'undefined' ? performance.now() : Date.now();
156
+ recorder._tracks = recorder._tracks || [];
157
+ screen.getTracks().forEach(function (track) {
158
+ recorder._tracks.push(track);
159
+ });
160
+
161
+ var opened = await recorder._connect(opts);
162
+ if (!opened || !opened.ok) {
163
+ if (typeof recorder._teardown === 'function') recorder._teardown();
164
+ else stopStream(screen);
165
+ return opened || { ok: false, error: 'The meeting session could not start.' };
166
+ }
167
+
168
+ recorder.recording = true;
169
+ var watching = recorder._startWatching(screen, opts);
170
+ return {
171
+ ok: true,
172
+ meetingId: recorder.meetingId,
173
+ audioMode: 'none',
174
+ watching: watching,
175
+ };
176
+ }
177
+
178
+ // A screen-only meeting has no audio sample clock. Keep slide timestamps meaningful by
179
+ // using elapsed wall time only while audioMode is explicitly `none`.
180
+ if (originalMediaClock) {
181
+ recorder._mediaClockMs = function () {
182
+ if (this.audioMode === 'none' && this._screenOnlyStartedAt != null) {
183
+ var now = typeof performance !== 'undefined' ? performance.now() : Date.now();
184
+ return Math.max(0, Math.round(now - this._screenOnlyStartedAt));
185
+ }
186
+ return originalMediaClock();
187
+ };
188
+ }
189
+
190
+ recorder.start = async function (options) {
191
+ var opts = options || {};
192
+ if (this.recording) return { ok: false, error: 'already recording' };
193
+ if (!opts.conversationId) return { ok: false, error: 'conversationId is required' };
194
+
195
+ var acquired = await acquire(opts);
196
+ if (!acquired.ok) return acquired;
197
+
198
+ var display = acquired.display;
199
+ var mic = acquired.mic;
200
+ var plan = acquired.plan;
201
+ var hasSystemAudio = !!(display && display.getAudioTracks && display.getAudioTracks().length);
202
+
203
+ if (!hasSystemAudio && !mic) {
204
+ if (plan.screen && display) return startScreenOnly(display, opts);
205
+ stopStream(display);
206
+ return {
207
+ ok: false,
208
+ error: 'Choose at least one capture source: Meeting audio, My microphone, or Screen & slides.',
209
+ audioMode: 'none',
210
+ };
211
+ }
212
+
213
+ if (!originalStartWithStreams) {
214
+ stopStream(display);
215
+ stopStream(mic);
216
+ return { ok: false, error: 'This MeetingSense build cannot accept routed media streams.' };
217
+ }
218
+
219
+ var result = await originalStartWithStreams({ screen: display, mic: mic }, opts);
220
+ if (!result || !result.ok) {
221
+ // startWithStreams normally tears down what it accepted on failure. This is a
222
+ // second idempotent stop so an older recorder cannot leave a granted device open.
223
+ stopStream(display);
224
+ stopStream(mic);
225
+ }
226
+ return result;
227
+ };
228
+
229
+ recorder.__homepilotMediaRoutingV1 = true;
230
+ recorder.mediaRouting = {
231
+ capturePlan: capturePlan,
232
+ microphoneConstraints: microphoneConstraints,
233
+ readPreferences: readPreferences,
234
+ errorText: errorText,
235
+ acquire: acquire,
236
+ };
237
+ })();
frontend/src/test/meetingsenseMediaRouting.test.js ADDED
@@ -0,0 +1,204 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import { beforeEach, describe, expect, it, vi } from 'vitest';
2
+ import { createHash } from 'node:crypto';
3
+ import { readFileSync } from 'node:fs';
4
+ import { dirname, resolve } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
6
+
7
+ const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../..');
8
+ const SHIPPED = resolve(ROOT, 'frontend/public/js/homepilot-meetingsense-media.js');
9
+ const MIRROR = resolve(ROOT, 'community/addons/meetingsense/homepilot-meetingsense-media.js');
10
+ const INDEX = resolve(ROOT, 'frontend/index.html');
11
+ const SOURCE = readFileSync(SHIPPED, 'utf8');
12
+
13
+ function fakeTrack(kind, label = '') {
14
+ return { kind, label, readyState: 'live', stop: vi.fn() };
15
+ }
16
+
17
+ function fakeStream({ audio = 0, video = 0 } = {}) {
18
+ const audioTracks = Array.from({ length: audio }, () => fakeTrack('audio'));
19
+ const videoTracks = Array.from({ length: video }, () => fakeTrack('video', 'Shared screen'));
20
+ return {
21
+ getAudioTracks: () => audioTracks,
22
+ getVideoTracks: () => videoTracks,
23
+ getTracks: () => [...audioTracks, ...videoTracks],
24
+ };
25
+ }
26
+
27
+ function install({ displayStream = fakeStream({ audio: 1, video: 1 }), micStream = fakeStream({ audio: 1 }) } = {}) {
28
+ const recorder = {
29
+ recording: false,
30
+ meetingId: 'meeting-1',
31
+ audioMode: 'none',
32
+ _elapsedSamples: 0,
33
+ _tracks: [],
34
+ _mediaClockMs: vi.fn(() => 0),
35
+ _connect: vi.fn(async () => ({ ok: true })),
36
+ _startWatching: vi.fn(() => true),
37
+ _teardown: vi.fn(),
38
+ startWithStreams: vi.fn(async () => ({ ok: true, meetingId: 'meeting-1', audioMode: 'system+mic' })),
39
+ internals: { trackLabel: vi.fn(() => 'Shared screen') },
40
+ };
41
+ const getDisplayMedia = vi.fn(async () => displayStream);
42
+ const getUserMedia = vi.fn(async () => micStream);
43
+ Object.defineProperty(navigator, 'mediaDevices', {
44
+ configurable: true,
45
+ value: { getDisplayMedia, getUserMedia },
46
+ });
47
+ window.hpMeetingSense = recorder;
48
+ // eslint-disable-next-line no-new-func
49
+ new Function(SOURCE).call(window);
50
+ return { recorder, getDisplayMedia, getUserMedia, displayStream, micStream };
51
+ }
52
+
53
+ beforeEach(() => {
54
+ localStorage.clear();
55
+ delete window.hpMeetingSense;
56
+ });
57
+
58
+ describe('MeetingSense media routing', () => {
59
+ it('ships the same routing contract in public and community addon copies', () => {
60
+ const digest = (path) => createHash('sha256').update(readFileSync(path)).digest('hex');
61
+ expect(digest(MIRROR)).toBe(digest(SHIPPED));
62
+ });
63
+
64
+ it('loads the routing contract after the recorder and before React', () => {
65
+ const html = readFileSync(INDEX, 'utf8');
66
+ const recorderAt = html.indexOf('/js/homepilot-meetingsense.js');
67
+ const routingAt = html.indexOf('/js/homepilot-meetingsense-media.js');
68
+ const reactAt = html.indexOf('/src/main.tsx');
69
+ expect(recorderAt).toBeGreaterThan(-1);
70
+ expect(routingAt).toBeGreaterThan(recorderAt);
71
+ expect(reactAt).toBeGreaterThan(routingAt);
72
+ });
73
+
74
+ it('does not open a microphone when My microphone is off', async () => {
75
+ const { recorder, getDisplayMedia, getUserMedia } = install();
76
+ const result = await window.hpMeetingSense.start({
77
+ conversationId: 'chat-1',
78
+ audio: true,
79
+ mic: false,
80
+ watch: false,
81
+ });
82
+
83
+ expect(result.ok).toBe(true);
84
+ expect(getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: true });
85
+ expect(getUserMedia).not.toHaveBeenCalled();
86
+ expect(recorder.startWithStreams).toHaveBeenCalledTimes(1);
87
+ expect(recorder.startWithStreams.mock.calls[0][0].mic).toBe(null);
88
+ });
89
+
90
+ it('uses the selected microphone and saved DSP settings for mic-only meetings', async () => {
91
+ localStorage.setItem('homepilot_media_preferences_v1', JSON.stringify({
92
+ microphoneDeviceId: 'usb-mic-42',
93
+ echoCancellation: false,
94
+ noiseSuppression: true,
95
+ autoGainControl: false,
96
+ }));
97
+ const { recorder, getDisplayMedia, getUserMedia } = install();
98
+
99
+ const result = await window.hpMeetingSense.start({
100
+ conversationId: 'chat-1',
101
+ audio: false,
102
+ mic: true,
103
+ watch: false,
104
+ });
105
+
106
+ expect(result.ok).toBe(true);
107
+ expect(getDisplayMedia).not.toHaveBeenCalled();
108
+ expect(getUserMedia).toHaveBeenCalledWith({
109
+ video: false,
110
+ audio: {
111
+ deviceId: { exact: 'usb-mic-42' },
112
+ echoCancellation: false,
113
+ noiseSuppression: true,
114
+ autoGainControl: false,
115
+ },
116
+ });
117
+ expect(recorder.startWithStreams).toHaveBeenCalledTimes(1);
118
+ });
119
+
120
+ it('requests screen video without display audio when Meeting audio is off', async () => {
121
+ const display = fakeStream({ video: 1 });
122
+ const { getDisplayMedia } = install({ displayStream: display });
123
+
124
+ const result = await window.hpMeetingSense.start({
125
+ conversationId: 'chat-1',
126
+ audio: false,
127
+ mic: true,
128
+ watch: true,
129
+ });
130
+
131
+ expect(result.ok).toBe(true);
132
+ expect(getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: false });
133
+ });
134
+
135
+ it('starts a screen-only meeting when both audio sources are off', async () => {
136
+ const display = fakeStream({ video: 1 });
137
+ const { recorder, getDisplayMedia, getUserMedia } = install({ displayStream: display });
138
+
139
+ const result = await window.hpMeetingSense.start({
140
+ conversationId: 'chat-1',
141
+ audio: false,
142
+ mic: false,
143
+ watch: true,
144
+ });
145
+
146
+ expect(result).toMatchObject({ ok: true, audioMode: 'none', watching: true });
147
+ expect(getDisplayMedia).toHaveBeenCalledWith({ video: true, audio: false });
148
+ expect(getUserMedia).not.toHaveBeenCalled();
149
+ expect(recorder.startWithStreams).not.toHaveBeenCalled();
150
+ expect(recorder._connect).toHaveBeenCalledTimes(1);
151
+ expect(recorder._startWatching).toHaveBeenCalledWith(display, expect.objectContaining({ watch: true }));
152
+ });
153
+
154
+ it('opens no browser capture when every source is off', async () => {
155
+ const { getDisplayMedia, getUserMedia } = install();
156
+ const result = await window.hpMeetingSense.start({
157
+ conversationId: 'chat-1',
158
+ audio: false,
159
+ mic: false,
160
+ watch: false,
161
+ });
162
+
163
+ expect(result.ok).toBe(false);
164
+ expect(result.error).toContain('Choose at least one capture source');
165
+ expect(getDisplayMedia).not.toHaveBeenCalled();
166
+ expect(getUserMedia).not.toHaveBeenCalled();
167
+ });
168
+
169
+ it('reports the real microphone error instead of silently continuing', async () => {
170
+ const display = fakeStream({ audio: 1, video: 1 });
171
+ const { getUserMedia } = install({ displayStream: display });
172
+ const error = new Error('device is already busy');
173
+ error.name = 'NotReadableError';
174
+ getUserMedia.mockRejectedValueOnce(error);
175
+
176
+ const result = await window.hpMeetingSense.start({
177
+ conversationId: 'chat-1',
178
+ audio: true,
179
+ mic: true,
180
+ watch: true,
181
+ });
182
+
183
+ expect(result.ok).toBe(false);
184
+ expect(result.error).toContain('Selected microphone could not be opened (NotReadableError)');
185
+ display.getTracks().forEach((track) => expect(track.stop).toHaveBeenCalledTimes(1));
186
+ });
187
+
188
+ it('fails clearly when Meeting audio was requested but the chosen share has no audio track', async () => {
189
+ const display = fakeStream({ video: 1 });
190
+ const { getUserMedia } = install({ displayStream: display });
191
+
192
+ const result = await window.hpMeetingSense.start({
193
+ conversationId: 'chat-1',
194
+ audio: true,
195
+ mic: true,
196
+ watch: true,
197
+ });
198
+
199
+ expect(result.ok).toBe(false);
200
+ expect(result.error).toContain('shared source has no audio track');
201
+ expect(getUserMedia).not.toHaveBeenCalled();
202
+ display.getTracks().forEach((track) => expect(track.stop).toHaveBeenCalledTimes(1));
203
+ });
204
+ });
frontend/src/ui/components/AudioVideoSettings.tsx CHANGED
@@ -37,6 +37,12 @@ type SinkAudioContext = AudioContext & {
37
  setSinkId?: (sinkId: string) => Promise<void>;
38
  };
39
 
 
 
 
 
 
 
40
  function Switch({ checked, label, onChange, disabled = false }: {
41
  checked: boolean;
42
  label: string;
@@ -129,37 +135,40 @@ function TestBadge({ state, idleLabel, runningLabel = 'Testing…', okLabel = 'W
129
  );
130
  }
131
 
132
- function friendlyError(error: unknown): string {
133
  const err = error as { name?: string; message?: string } | null;
134
  if (err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError') {
135
- return 'Permission was blocked. Allow camera/microphone access in your browser, then try again.';
136
  }
137
  if (err?.name === 'NotFoundError' || err?.name === 'DevicesNotFoundError') {
138
- return 'The selected device is no longer available. Choose another device or System default.';
139
  }
140
  if (err?.name === 'OverconstrainedError') {
141
- return 'The selected device cannot satisfy this configuration. Choose another device.';
142
  }
143
  if (err?.name === 'NotReadableError' || err?.name === 'TrackStartError') {
144
- return 'The device is busy in another app or could not be started.';
145
  }
146
- return err?.message || 'The media test could not start.';
147
  }
148
 
149
  export default function AudioVideoSettings() {
150
  const [preferences, setPreferences] = useState<MediaPreferences>(() => getMediaPreferences());
151
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
152
  const [deviceError, setDeviceError] = useState<string | null>(null);
153
- const [previewState, setPreviewState] = useState<TestState>('idle');
 
154
  const [speakerState, setSpeakerState] = useState<TestState>('idle');
155
  const [screenState, setScreenState] = useState<TestState>('idle');
156
- const [previewMessage, setPreviewMessage] = useState<string | null>(null);
 
157
  const [speakerMessage, setSpeakerMessage] = useState<string | null>(null);
158
  const [screenMessage, setScreenMessage] = useState<string | null>(null);
159
  const [micLevel, setMicLevel] = useState(0);
160
 
161
  const videoRef = useRef<HTMLVideoElement | null>(null);
162
- const streamRef = useRef<MediaStream | null>(null);
 
163
  const audioContextRef = useRef<AudioContext | null>(null);
164
  const animationRef = useRef<number>(0);
165
 
@@ -178,12 +187,17 @@ export default function AudioVideoSettings() {
178
  const speakers = devices.filter((device) => device.kind === 'audiooutput');
179
 
180
  const persist = useCallback((patch: Partial<MediaPreferences>) => {
181
- setPreferences((current) => {
182
- const next = setMediaPreferences({ ...current, ...patch });
183
- return next;
184
- });
185
- if (streamRef.current) {
186
- setPreviewMessage('Settings changed. Restart the camera & microphone test to verify the new source.');
 
 
 
 
 
187
  }
188
  }, []);
189
 
@@ -193,26 +207,42 @@ export default function AudioVideoSettings() {
193
  setDeviceError(null);
194
  setDevices(await navigator.mediaDevices.enumerateDevices());
195
  } catch (error) {
196
- setDeviceError(friendlyError(error));
197
  }
198
  }, []);
199
 
200
- const stopPreview = useCallback(() => {
 
 
 
 
 
 
201
  if (animationRef.current) {
202
  cancelAnimationFrame(animationRef.current);
203
  animationRef.current = 0;
204
  }
205
- streamRef.current?.getTracks().forEach((track) => track.stop());
206
- streamRef.current = null;
207
- if (videoRef.current) videoRef.current.srcObject = null;
208
  if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
209
  audioContextRef.current.close().catch(() => {});
210
  }
211
  audioContextRef.current = null;
212
  setMicLevel(0);
213
- setPreviewState('idle');
214
  }, []);
215
 
 
 
 
 
 
 
 
 
 
 
 
 
216
  useEffect(() => {
217
  if (!mediaSupported) return;
218
  void refreshDevices();
@@ -220,28 +250,58 @@ export default function AudioVideoSettings() {
220
  navigator.mediaDevices.addEventListener?.('devicechange', onDeviceChange);
221
  return () => {
222
  navigator.mediaDevices.removeEventListener?.('devicechange', onDeviceChange);
223
- stopPreview();
 
224
  };
225
- }, [mediaSupported, refreshDevices, stopPreview]);
226
 
227
- const startPreview = useCallback(async () => {
228
  if (!mediaSupported) return;
229
- stopPreview();
230
- setPreviewState('running');
231
- setPreviewMessage(null);
232
 
233
  try {
234
  const stream = await navigator.mediaDevices.getUserMedia({
235
  video: buildVideoConstraints(preferences),
236
- audio: buildAudioConstraints(preferences),
237
  });
238
- streamRef.current = stream;
 
 
239
 
240
  if (videoRef.current) {
241
  videoRef.current.srcObject = stream;
242
  await videoRef.current.play().catch(() => undefined);
243
  }
244
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
245
  const AudioContextCtor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
246
  if (AudioContextCtor) {
247
  const ctx = new AudioContextCtor();
@@ -255,7 +315,7 @@ export default function AudioVideoSettings() {
255
  const data = new Uint8Array(analyser.fftSize);
256
 
257
  const readLevel = () => {
258
- if (!streamRef.current || audioContextRef.current !== ctx) return;
259
  analyser.getByteTimeDomainData(data);
260
  let sum = 0;
261
  for (let i = 0; i < data.length; i += 1) {
@@ -269,21 +329,24 @@ export default function AudioVideoSettings() {
269
  readLevel();
270
  }
271
 
272
- const videoSettings = stream.getVideoTracks()[0]?.getSettings();
273
- const audioSettings = stream.getAudioTracks()[0]?.getSettings();
274
- const parts = [
275
- videoSettings?.width && videoSettings?.height ? `${videoSettings.width}×${videoSettings.height}` : null,
276
- audioSettings?.sampleRate ? `${Math.round(audioSettings.sampleRate / 1000)} kHz mic` : 'microphone active',
 
277
  ].filter(Boolean);
278
- setPreviewMessage(`Live test is using the selected devices${parts.length ? ` (${parts.join(', ')})` : ''}.`);
279
- setPreviewState('ok');
 
 
280
  await refreshDevices();
281
  } catch (error) {
282
- stopPreview();
283
- setPreviewState('error');
284
- setPreviewMessage(friendlyError(error));
285
  }
286
- }, [mediaSupported, preferences, refreshDevices, stopPreview]);
287
 
288
  const chooseSpeaker = useCallback(async () => {
289
  const outputDevices = navigator.mediaDevices as OutputMediaDevices;
@@ -303,7 +366,7 @@ export default function AudioVideoSettings() {
303
  } catch (error) {
304
  if ((error as { name?: string })?.name === 'NotAllowedError') return;
305
  setSpeakerState('error');
306
- setSpeakerMessage(friendlyError(error));
307
  }
308
  }, [persist, preferences.speakerDeviceId, refreshDevices]);
309
 
@@ -336,7 +399,7 @@ export default function AudioVideoSettings() {
336
  setSpeakerMessage('Test tone played. If you heard it, speaker output is working.');
337
  } catch (error) {
338
  setSpeakerState('error');
339
- setSpeakerMessage(friendlyError(error));
340
  } finally {
341
  await ctx?.close().catch(() => undefined);
342
  }
@@ -363,7 +426,7 @@ export default function AudioVideoSettings() {
363
  setScreenMessage('Screen-share test cancelled.');
364
  } else {
365
  setScreenState('error');
366
- setScreenMessage(friendlyError(error));
367
  }
368
  } finally {
369
  stream?.getTracks().forEach((track) => track.stop());
@@ -372,13 +435,13 @@ export default function AudioVideoSettings() {
372
 
373
  const deviceLabel = (device: MediaDeviceInfo, index: number, fallback: string) => device.label || `${fallback} ${index + 1}`;
374
  const selectedSpeaker = speakers.find((speaker) => speaker.deviceId === preferences.speakerDeviceId);
375
- const selectAudioOutputSupported = !!(navigator.mediaDevices as OutputMediaDevices | undefined)?.selectAudioOutput;
376
 
377
  if (!mediaSupported || !secureEnough) {
378
  return (
379
  <Card
380
  title="Audio & Video"
381
- description="Choose and verify the devices HomePilot uses for live conversations."
382
  icon={<Camera size={16} />}
383
  >
384
  <div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-4 text-xs text-amber-200/90 leading-relaxed">
@@ -394,10 +457,10 @@ export default function AudioVideoSettings() {
394
  <div className="space-y-4">
395
  <Card
396
  title="Camera"
397
- description="Select the camera used for live video and verify the real capture before a conversation."
398
  icon={<Camera size={16} />}
399
  >
400
- <SettingRow label="Camera" description="Device labels appear after you grant media permission." stack>
401
  <div className="flex flex-col sm:flex-row gap-2">
402
  <select
403
  aria-label="Camera"
@@ -429,26 +492,39 @@ export default function AudioVideoSettings() {
429
  className={[
430
  'w-full h-full object-cover',
431
  preferences.mirrorCameraPreview ? '-scale-x-100' : '',
432
- previewState === 'ok' ? 'opacity-100' : 'opacity-40',
433
  ].join(' ')}
434
  />
435
- {previewState !== 'ok' && (
436
  <div className="absolute inset-0 flex items-center justify-center text-center p-6">
437
  <div>
438
  <Camera size={24} className="mx-auto text-white/25 mb-2" />
439
- <div className="text-xs text-white/45">Start the device test to see your camera.</div>
440
  </div>
441
  </div>
442
  )}
443
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
444
  </Card>
445
 
446
  <Card
447
  title="Microphone"
448
- description="HomePilot Voice uses this same microphone and the same processing settings below."
449
  icon={<Mic2 size={16} />}
450
  >
451
- <SettingRow label="Microphone" stack>
452
  <select
453
  aria-label="Microphone"
454
  className={SELECT_CLS}
@@ -462,14 +538,14 @@ export default function AudioVideoSettings() {
462
  </select>
463
  </SettingRow>
464
 
465
- <SettingRow label="Input level" description="Speak normally. The meter should move without staying pinned at 100%.">
466
  <div className="h-2.5 rounded-full bg-white/10 overflow-hidden border border-white/[0.06]" aria-label={`Microphone input level ${Math.round(micLevel * 100)}%`}>
467
- <div className="h-full bg-[#9b5cff] transition-[width] duration-75" style={{ width: `${Math.max(2, micLevel * 100)}%` }} />
468
  </div>
469
  </SettingRow>
470
 
471
  <div className="border-t border-white/[0.06] pt-4 space-y-4">
472
- <SettingRow label="Echo cancellation" description="Reduces sound from HomePilot or other speakers feeding back into your mic.">
473
  <Switch
474
  checked={preferences.echoCancellation}
475
  label="Echo cancellation"
@@ -496,17 +572,17 @@ export default function AudioVideoSettings() {
496
  </div>
497
 
498
  <div className="flex flex-col sm:flex-row sm:items-center gap-3 pt-1">
499
- {previewState === 'ok' ? (
500
- <button type="button" className={BUTTON_CLS} onClick={stopPreview}><Square size={14} /> Stop camera &amp; mic test</button>
501
  ) : (
502
- <button type="button" className={BUTTON_CLS} onClick={() => void startPreview()} disabled={previewState === 'running'}>
503
- {previewState === 'running' ? <RefreshCw size={14} className="animate-spin" /> : <Play size={14} />}
504
- Test camera &amp; microphone
505
  </button>
506
  )}
507
- <TestBadge state={previewState} idleLabel="Not tested" okLabel="Live" />
508
  </div>
509
- {previewMessage ? <p className={previewState === 'error' ? 'text-xs text-red-300/80' : 'text-xs text-white/45'}>{previewMessage}</p> : null}
510
  </Card>
511
 
512
  <Card
@@ -536,31 +612,42 @@ export default function AudioVideoSettings() {
536
 
537
  <Card
538
  title="Troubleshooting"
539
- description="Verify the browser permissions and capture paths HomePilot depends on."
540
  icon={<ShieldCheck size={16} />}
541
  >
542
- <div className="grid gap-3 sm:grid-cols-2">
543
  <div className="rounded-xl bg-[#050505] border border-white/10 p-4 space-y-3">
544
  <div className="flex items-center justify-between gap-2">
545
- <div className="flex items-center gap-2 text-sm text-white/80"><Camera size={15} /> Camera &amp; microphone</div>
546
- <TestBadge state={previewState} idleLabel="Not tested" />
547
  </div>
548
- <p className="text-[11px] text-white/40 leading-relaxed">Checks real camera frames, microphone capture, the input meter, and your selected DSP constraints.</p>
549
- <button type="button" className={BUTTON_CLS + ' w-full'} onClick={() => void startPreview()} disabled={previewState === 'running'}>Run test</button>
550
  </div>
 
 
 
 
 
 
 
 
 
 
551
  <div className="rounded-xl bg-[#050505] border border-white/10 p-4 space-y-3">
552
  <div className="flex items-center justify-between gap-2">
553
  <div className="flex items-center gap-2 text-sm text-white/80"><MonitorUp size={15} /> Screen sharing</div>
554
  <TestBadge state={screenState} idleLabel="Not tested" />
555
  </div>
556
- <p className="text-[11px] text-white/40 leading-relaxed">Opens the browser's screen picker, verifies a live display track, then stops the test immediately.</p>
557
- <button type="button" className={BUTTON_CLS + ' w-full'} onClick={() => void testScreenSharing()} disabled={screenState === 'running'}>Run test</button>
558
  </div>
559
  </div>
 
560
  {screenMessage ? <p className={screenState === 'error' ? 'text-xs text-red-300/80' : 'text-xs text-white/45'}>{screenMessage}</p> : null}
561
  {deviceError ? <p className="text-xs text-red-300/80">Device discovery: {deviceError}</p> : null}
562
  <div className="rounded-xl border border-white/[0.07] bg-white/[0.025] px-3.5 py-3 text-[11px] text-white/40 leading-relaxed">
563
- Device choices and microphone processing are saved automatically on this browser. HomePilot Voice reads the same microphone source and processing preferences when it starts listening.
564
  </div>
565
  </Card>
566
  </div>
 
37
  setSinkId?: (sinkId: string) => Promise<void>;
38
  };
39
 
40
+ type AudioTrackSettings = MediaTrackSettings & {
41
+ echoCancellation?: boolean;
42
+ noiseSuppression?: boolean;
43
+ autoGainControl?: boolean;
44
+ };
45
+
46
  function Switch({ checked, label, onChange, disabled = false }: {
47
  checked: boolean;
48
  label: string;
 
135
  );
136
  }
137
 
138
+ function friendlyError(error: unknown, subject = 'device'): string {
139
  const err = error as { name?: string; message?: string } | null;
140
  if (err?.name === 'NotAllowedError' || err?.name === 'PermissionDeniedError') {
141
+ return `${subject} permission was blocked. Allow it in your browser, then try again.`;
142
  }
143
  if (err?.name === 'NotFoundError' || err?.name === 'DevicesNotFoundError') {
144
+ return `The selected ${subject.toLowerCase()} is no longer available. Choose another device or System default.`;
145
  }
146
  if (err?.name === 'OverconstrainedError') {
147
+ return `The selected ${subject.toLowerCase()} cannot satisfy this configuration. Choose another device.`;
148
  }
149
  if (err?.name === 'NotReadableError' || err?.name === 'TrackStartError') {
150
+ return `The ${subject.toLowerCase()} is busy in another app or could not be started.`;
151
  }
152
+ return err?.message || `The ${subject.toLowerCase()} test could not start.`;
153
  }
154
 
155
  export default function AudioVideoSettings() {
156
  const [preferences, setPreferences] = useState<MediaPreferences>(() => getMediaPreferences());
157
  const [devices, setDevices] = useState<MediaDeviceInfo[]>([]);
158
  const [deviceError, setDeviceError] = useState<string | null>(null);
159
+ const [cameraState, setCameraState] = useState<TestState>('idle');
160
+ const [microphoneState, setMicrophoneState] = useState<TestState>('idle');
161
  const [speakerState, setSpeakerState] = useState<TestState>('idle');
162
  const [screenState, setScreenState] = useState<TestState>('idle');
163
+ const [cameraMessage, setCameraMessage] = useState<string | null>(null);
164
+ const [microphoneMessage, setMicrophoneMessage] = useState<string | null>(null);
165
  const [speakerMessage, setSpeakerMessage] = useState<string | null>(null);
166
  const [screenMessage, setScreenMessage] = useState<string | null>(null);
167
  const [micLevel, setMicLevel] = useState(0);
168
 
169
  const videoRef = useRef<HTMLVideoElement | null>(null);
170
+ const cameraStreamRef = useRef<MediaStream | null>(null);
171
+ const microphoneStreamRef = useRef<MediaStream | null>(null);
172
  const audioContextRef = useRef<AudioContext | null>(null);
173
  const animationRef = useRef<number>(0);
174
 
 
187
  const speakers = devices.filter((device) => device.kind === 'audiooutput');
188
 
189
  const persist = useCallback((patch: Partial<MediaPreferences>) => {
190
+ setPreferences((current) => setMediaPreferences({ ...current, ...patch }));
191
+
192
+ if (cameraStreamRef.current && Object.prototype.hasOwnProperty.call(patch, 'cameraDeviceId')) {
193
+ setCameraMessage('Camera changed. Restart the camera test to verify the new source.');
194
+ }
195
+ if (
196
+ microphoneStreamRef.current &&
197
+ ['microphoneDeviceId', 'echoCancellation', 'noiseSuppression', 'autoGainControl']
198
+ .some((key) => Object.prototype.hasOwnProperty.call(patch, key))
199
+ ) {
200
+ setMicrophoneMessage('Microphone settings changed. Restart the microphone test to verify them.');
201
  }
202
  }, []);
203
 
 
207
  setDeviceError(null);
208
  setDevices(await navigator.mediaDevices.enumerateDevices());
209
  } catch (error) {
210
+ setDeviceError(friendlyError(error, 'Device discovery'));
211
  }
212
  }, []);
213
 
214
+ const releaseCamera = useCallback(() => {
215
+ cameraStreamRef.current?.getTracks().forEach((track) => track.stop());
216
+ cameraStreamRef.current = null;
217
+ if (videoRef.current) videoRef.current.srcObject = null;
218
+ }, []);
219
+
220
+ const releaseMicrophone = useCallback(() => {
221
  if (animationRef.current) {
222
  cancelAnimationFrame(animationRef.current);
223
  animationRef.current = 0;
224
  }
225
+ microphoneStreamRef.current?.getTracks().forEach((track) => track.stop());
226
+ microphoneStreamRef.current = null;
 
227
  if (audioContextRef.current && audioContextRef.current.state !== 'closed') {
228
  audioContextRef.current.close().catch(() => {});
229
  }
230
  audioContextRef.current = null;
231
  setMicLevel(0);
 
232
  }, []);
233
 
234
+ const stopCamera = useCallback(() => {
235
+ releaseCamera();
236
+ setCameraState('idle');
237
+ setCameraMessage('Camera test stopped.');
238
+ }, [releaseCamera]);
239
+
240
+ const stopMicrophone = useCallback(() => {
241
+ releaseMicrophone();
242
+ setMicrophoneState('idle');
243
+ setMicrophoneMessage('Microphone test stopped.');
244
+ }, [releaseMicrophone]);
245
+
246
  useEffect(() => {
247
  if (!mediaSupported) return;
248
  void refreshDevices();
 
250
  navigator.mediaDevices.addEventListener?.('devicechange', onDeviceChange);
251
  return () => {
252
  navigator.mediaDevices.removeEventListener?.('devicechange', onDeviceChange);
253
+ releaseCamera();
254
+ releaseMicrophone();
255
  };
256
+ }, [mediaSupported, refreshDevices, releaseCamera, releaseMicrophone]);
257
 
258
+ const startCameraTest = useCallback(async () => {
259
  if (!mediaSupported) return;
260
+ releaseCamera();
261
+ setCameraState('running');
262
+ setCameraMessage(null);
263
 
264
  try {
265
  const stream = await navigator.mediaDevices.getUserMedia({
266
  video: buildVideoConstraints(preferences),
267
+ audio: false,
268
  });
269
+ const track = stream.getVideoTracks()[0];
270
+ if (!track || track.readyState !== 'live') throw new Error('No live camera track was returned.');
271
+ cameraStreamRef.current = stream;
272
 
273
  if (videoRef.current) {
274
  videoRef.current.srcObject = stream;
275
  await videoRef.current.play().catch(() => undefined);
276
  }
277
 
278
+ const settings = track.getSettings();
279
+ const size = settings.width && settings.height ? `${settings.width}×${settings.height}` : 'live video';
280
+ setCameraState('ok');
281
+ setCameraMessage(`Camera is live (${size}). This test did not open the microphone.`);
282
+ await refreshDevices();
283
+ } catch (error) {
284
+ releaseCamera();
285
+ setCameraState('error');
286
+ setCameraMessage(friendlyError(error, 'Camera'));
287
+ }
288
+ }, [mediaSupported, preferences, refreshDevices, releaseCamera]);
289
+
290
+ const startMicrophoneTest = useCallback(async () => {
291
+ if (!mediaSupported) return;
292
+ releaseMicrophone();
293
+ setMicrophoneState('running');
294
+ setMicrophoneMessage(null);
295
+
296
+ try {
297
+ const stream = await navigator.mediaDevices.getUserMedia({
298
+ video: false,
299
+ audio: buildAudioConstraints(preferences),
300
+ });
301
+ const track = stream.getAudioTracks()[0];
302
+ if (!track || track.readyState !== 'live') throw new Error('No live microphone track was returned.');
303
+ microphoneStreamRef.current = stream;
304
+
305
  const AudioContextCtor = window.AudioContext || (window as typeof window & { webkitAudioContext?: typeof AudioContext }).webkitAudioContext;
306
  if (AudioContextCtor) {
307
  const ctx = new AudioContextCtor();
 
315
  const data = new Uint8Array(analyser.fftSize);
316
 
317
  const readLevel = () => {
318
+ if (!microphoneStreamRef.current || audioContextRef.current !== ctx) return;
319
  analyser.getByteTimeDomainData(data);
320
  let sum = 0;
321
  for (let i = 0; i < data.length; i += 1) {
 
329
  readLevel();
330
  }
331
 
332
+ const settings = track.getSettings() as AudioTrackSettings;
333
+ const sampleRate = settings.sampleRate ? `${Math.round(settings.sampleRate / 1000)} kHz` : 'live input';
334
+ const processing = [
335
+ settings.echoCancellation === true ? 'echo cancellation' : null,
336
+ settings.noiseSuppression === true ? 'noise suppression' : null,
337
+ settings.autoGainControl === true ? 'auto gain' : null,
338
  ].filter(Boolean);
339
+ setMicrophoneState('ok');
340
+ setMicrophoneMessage(
341
+ `Microphone is live (${sampleRate}${processing.length ? `; ${processing.join(', ')}` : ''}). This test did not open the camera.`,
342
+ );
343
  await refreshDevices();
344
  } catch (error) {
345
+ releaseMicrophone();
346
+ setMicrophoneState('error');
347
+ setMicrophoneMessage(friendlyError(error, 'Microphone'));
348
  }
349
+ }, [mediaSupported, preferences, refreshDevices, releaseMicrophone]);
350
 
351
  const chooseSpeaker = useCallback(async () => {
352
  const outputDevices = navigator.mediaDevices as OutputMediaDevices;
 
366
  } catch (error) {
367
  if ((error as { name?: string })?.name === 'NotAllowedError') return;
368
  setSpeakerState('error');
369
+ setSpeakerMessage(friendlyError(error, 'Speaker'));
370
  }
371
  }, [persist, preferences.speakerDeviceId, refreshDevices]);
372
 
 
399
  setSpeakerMessage('Test tone played. If you heard it, speaker output is working.');
400
  } catch (error) {
401
  setSpeakerState('error');
402
+ setSpeakerMessage(friendlyError(error, 'Speaker'));
403
  } finally {
404
  await ctx?.close().catch(() => undefined);
405
  }
 
426
  setScreenMessage('Screen-share test cancelled.');
427
  } else {
428
  setScreenState('error');
429
+ setScreenMessage(friendlyError(error, 'Screen sharing'));
430
  }
431
  } finally {
432
  stream?.getTracks().forEach((track) => track.stop());
 
435
 
436
  const deviceLabel = (device: MediaDeviceInfo, index: number, fallback: string) => device.label || `${fallback} ${index + 1}`;
437
  const selectedSpeaker = speakers.find((speaker) => speaker.deviceId === preferences.speakerDeviceId);
438
+ const selectAudioOutputSupported = typeof navigator !== 'undefined' && !!(navigator.mediaDevices as OutputMediaDevices | undefined)?.selectAudioOutput;
439
 
440
  if (!mediaSupported || !secureEnough) {
441
  return (
442
  <Card
443
  title="Audio & Video"
444
+ description="Choose and verify the devices HomePilot uses for live conversations and meetings."
445
  icon={<Camera size={16} />}
446
  >
447
  <div className="rounded-xl border border-amber-500/25 bg-amber-500/10 p-4 text-xs text-amber-200/90 leading-relaxed">
 
457
  <div className="space-y-4">
458
  <Card
459
  title="Camera"
460
+ description="Select and test video independently. Testing the camera never opens your microphone."
461
  icon={<Camera size={16} />}
462
  >
463
+ <SettingRow label="Camera" description="Device labels appear after you grant camera permission." stack>
464
  <div className="flex flex-col sm:flex-row gap-2">
465
  <select
466
  aria-label="Camera"
 
492
  className={[
493
  'w-full h-full object-cover',
494
  preferences.mirrorCameraPreview ? '-scale-x-100' : '',
495
+ cameraState === 'ok' ? 'opacity-100' : 'opacity-40',
496
  ].join(' ')}
497
  />
498
+ {cameraState !== 'ok' && (
499
  <div className="absolute inset-0 flex items-center justify-center text-center p-6">
500
  <div>
501
  <Camera size={24} className="mx-auto text-white/25 mb-2" />
502
+ <div className="text-xs text-white/45">Run the camera test to see your preview.</div>
503
  </div>
504
  </div>
505
  )}
506
  </div>
507
+
508
+ <div className="flex flex-col sm:flex-row sm:items-center gap-3">
509
+ {cameraState === 'ok' ? (
510
+ <button type="button" className={BUTTON_CLS} onClick={stopCamera}><Square size={14} /> Stop camera test</button>
511
+ ) : (
512
+ <button type="button" className={BUTTON_CLS} onClick={() => void startCameraTest()} disabled={cameraState === 'running'}>
513
+ {cameraState === 'running' ? <RefreshCw size={14} className="animate-spin" /> : <Play size={14} />}
514
+ Test camera
515
+ </button>
516
+ )}
517
+ <TestBadge state={cameraState} idleLabel="Not tested" okLabel="Live" />
518
+ </div>
519
+ {cameraMessage ? <p className={cameraState === 'error' ? 'text-xs text-red-300/80' : 'text-xs text-white/45'}>{cameraMessage}</p> : null}
520
  </Card>
521
 
522
  <Card
523
  title="Microphone"
524
+ description="Select and test audio independently. HomePilot Voice and MeetingSense use these same microphone preferences."
525
  icon={<Mic2 size={16} />}
526
  >
527
+ <SettingRow label="Microphone" description="The test opens only this audio input; it does not start the camera." stack>
528
  <select
529
  aria-label="Microphone"
530
  className={SELECT_CLS}
 
538
  </select>
539
  </SettingRow>
540
 
541
+ <SettingRow label="Input level" description="Speak normally. During a microphone test the meter should move without staying pinned at 100%.">
542
  <div className="h-2.5 rounded-full bg-white/10 overflow-hidden border border-white/[0.06]" aria-label={`Microphone input level ${Math.round(micLevel * 100)}%`}>
543
+ <div className="h-full bg-[#9b5cff] transition-[width] duration-75" style={{ width: `${microphoneState === 'ok' ? Math.max(2, micLevel * 100) : 0}%` }} />
544
  </div>
545
  </SettingRow>
546
 
547
  <div className="border-t border-white/[0.06] pt-4 space-y-4">
548
+ <SettingRow label="Echo cancellation" description="Reduces sound from speakers feeding back into your microphone.">
549
  <Switch
550
  checked={preferences.echoCancellation}
551
  label="Echo cancellation"
 
572
  </div>
573
 
574
  <div className="flex flex-col sm:flex-row sm:items-center gap-3 pt-1">
575
+ {microphoneState === 'ok' ? (
576
+ <button type="button" className={BUTTON_CLS} onClick={stopMicrophone}><Square size={14} /> Stop microphone test</button>
577
  ) : (
578
+ <button type="button" className={BUTTON_CLS} onClick={() => void startMicrophoneTest()} disabled={microphoneState === 'running'}>
579
+ {microphoneState === 'running' ? <RefreshCw size={14} className="animate-spin" /> : <Play size={14} />}
580
+ Test microphone
581
  </button>
582
  )}
583
+ <TestBadge state={microphoneState} idleLabel="Not tested" okLabel="Listening" />
584
  </div>
585
+ {microphoneMessage ? <p className={microphoneState === 'error' ? 'text-xs text-red-300/80' : 'text-xs text-white/45'}>{microphoneMessage}</p> : null}
586
  </Card>
587
 
588
  <Card
 
612
 
613
  <Card
614
  title="Troubleshooting"
615
+ description="Run audio, video, and screen-sharing tests independently so a failure identifies the exact capture path."
616
  icon={<ShieldCheck size={16} />}
617
  >
618
+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
619
  <div className="rounded-xl bg-[#050505] border border-white/10 p-4 space-y-3">
620
  <div className="flex items-center justify-between gap-2">
621
+ <div className="flex items-center gap-2 text-sm text-white/80"><Camera size={15} /> Camera</div>
622
+ <TestBadge state={cameraState} idleLabel="Not tested" />
623
  </div>
624
+ <p className="text-[11px] text-white/40 leading-relaxed">Checks the selected camera and real video frames only. It never opens the microphone.</p>
625
+ <button type="button" className={BUTTON_CLS + ' w-full'} onClick={() => void startCameraTest()} disabled={cameraState === 'running'}>Run video test</button>
626
  </div>
627
+
628
+ <div className="rounded-xl bg-[#050505] border border-white/10 p-4 space-y-3">
629
+ <div className="flex items-center justify-between gap-2">
630
+ <div className="flex items-center gap-2 text-sm text-white/80"><Mic2 size={15} /> Microphone</div>
631
+ <TestBadge state={microphoneState} idleLabel="Not tested" />
632
+ </div>
633
+ <p className="text-[11px] text-white/40 leading-relaxed">Checks the selected microphone, input meter, and DSP constraints only. It never opens the camera.</p>
634
+ <button type="button" className={BUTTON_CLS + ' w-full'} onClick={() => void startMicrophoneTest()} disabled={microphoneState === 'running'}>Run audio test</button>
635
+ </div>
636
+
637
  <div className="rounded-xl bg-[#050505] border border-white/10 p-4 space-y-3">
638
  <div className="flex items-center justify-between gap-2">
639
  <div className="flex items-center gap-2 text-sm text-white/80"><MonitorUp size={15} /> Screen sharing</div>
640
  <TestBadge state={screenState} idleLabel="Not tested" />
641
  </div>
642
+ <p className="text-[11px] text-white/40 leading-relaxed">Opens the browser screen picker, verifies a live display track, then stops the test immediately.</p>
643
+ <button type="button" className={BUTTON_CLS + ' w-full'} onClick={() => void testScreenSharing()} disabled={screenState === 'running'}>Run screen test</button>
644
  </div>
645
  </div>
646
+
647
  {screenMessage ? <p className={screenState === 'error' ? 'text-xs text-red-300/80' : 'text-xs text-white/45'}>{screenMessage}</p> : null}
648
  {deviceError ? <p className="text-xs text-red-300/80">Device discovery: {deviceError}</p> : null}
649
  <div className="rounded-xl border border-white/[0.07] bg-white/[0.025] px-3.5 py-3 text-[11px] text-white/40 leading-relaxed">
650
+ Device choices and microphone processing are saved automatically on this browser. HomePilot Voice and MeetingSense both use the same selected microphone and processing preferences.
651
  </div>
652
  </Card>
653
  </div>