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 |
-
|
|
|
|
| 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
|
| 136 |
}
|
| 137 |
if (err?.name === 'NotFoundError' || err?.name === 'DevicesNotFoundError') {
|
| 138 |
-
return
|
| 139 |
}
|
| 140 |
if (err?.name === 'OverconstrainedError') {
|
| 141 |
-
return
|
| 142 |
}
|
| 143 |
if (err?.name === 'NotReadableError' || err?.name === 'TrackStartError') {
|
| 144 |
-
return
|
| 145 |
}
|
| 146 |
-
return err?.message ||
|
| 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 [
|
|
|
|
| 154 |
const [speakerState, setSpeakerState] = useState<TestState>('idle');
|
| 155 |
const [screenState, setScreenState] = useState<TestState>('idle');
|
| 156 |
-
const [
|
|
|
|
| 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
|
|
|
|
| 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 |
-
|
| 183 |
-
|
| 184 |
-
|
| 185 |
-
|
| 186 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
if (animationRef.current) {
|
| 202 |
cancelAnimationFrame(animationRef.current);
|
| 203 |
animationRef.current = 0;
|
| 204 |
}
|
| 205 |
-
|
| 206 |
-
|
| 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 |
-
|
|
|
|
| 224 |
};
|
| 225 |
-
}, [mediaSupported, refreshDevices,
|
| 226 |
|
| 227 |
-
const
|
| 228 |
if (!mediaSupported) return;
|
| 229 |
-
|
| 230 |
-
|
| 231 |
-
|
| 232 |
|
| 233 |
try {
|
| 234 |
const stream = await navigator.mediaDevices.getUserMedia({
|
| 235 |
video: buildVideoConstraints(preferences),
|
| 236 |
-
audio:
|
| 237 |
});
|
| 238 |
-
|
|
|
|
|
|
|
| 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 (!
|
| 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
|
| 273 |
-
const
|
| 274 |
-
const
|
| 275 |
-
|
| 276 |
-
|
|
|
|
| 277 |
].filter(Boolean);
|
| 278 |
-
|
| 279 |
-
|
|
|
|
|
|
|
| 280 |
await refreshDevices();
|
| 281 |
} catch (error) {
|
| 282 |
-
|
| 283 |
-
|
| 284 |
-
|
| 285 |
}
|
| 286 |
-
}, [mediaSupported, preferences, refreshDevices,
|
| 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
|
| 398 |
icon={<Camera size={16} />}
|
| 399 |
>
|
| 400 |
-
<SettingRow label="Camera" description="Device labels appear after you grant
|
| 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 |
-
|
| 433 |
].join(' ')}
|
| 434 |
/>
|
| 435 |
-
{
|
| 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">
|
| 440 |
</div>
|
| 441 |
</div>
|
| 442 |
)}
|
| 443 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 444 |
</Card>
|
| 445 |
|
| 446 |
<Card
|
| 447 |
title="Microphone"
|
| 448 |
-
description="
|
| 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.
|
| 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
|
| 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 |
-
{
|
| 500 |
-
<button type="button" className={BUTTON_CLS} onClick={
|
| 501 |
) : (
|
| 502 |
-
<button type="button" className={BUTTON_CLS} onClick={() => void
|
| 503 |
-
{
|
| 504 |
-
Test
|
| 505 |
</button>
|
| 506 |
)}
|
| 507 |
-
<TestBadge state={
|
| 508 |
</div>
|
| 509 |
-
{
|
| 510 |
</Card>
|
| 511 |
|
| 512 |
<Card
|
|
@@ -536,31 +612,42 @@ export default function AudioVideoSettings() {
|
|
| 536 |
|
| 537 |
<Card
|
| 538 |
title="Troubleshooting"
|
| 539 |
-
description="
|
| 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
|
| 546 |
-
<TestBadge state={
|
| 547 |
</div>
|
| 548 |
-
<p className="text-[11px] text-white/40 leading-relaxed">Checks
|
| 549 |
-
<button type="button" className={BUTTON_CLS + ' w-full'} onClick={() => void
|
| 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
|
| 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
|
| 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>
|