Skip to content

Commit ce34e5d

Browse files
feat(huddle): add 'headphones recommended' banner while AEC is missing
Adds a session-dismissable banner inside HuddleBar that warns users to use headphones until echo cancellation lands. Lives in a new tiny HeadphonesNotice component so it can be deleted in one diff when the AEC follow-up PR flips aecMissing to false. Why it's needed for this PR: the FIFO/jitter fix in commit 25b380f and the protocol v2 frame header in commit b6dd829 together make 10-person huddles work *for everyone wearing headphones*. The play path is still native rodio (outside the WebView render graph), so the browser's WebRTC echo canceller has nothing to cancel against. Two users on speakers in the same physical room would hear themselves echo. The banner makes that limitation visible up-front rather than letting users discover it when they're already in a 10-person meeting. Quinn's tripwire from the design thread: AEC v1 is the next PR after this one merges, not 'soon' or 'in the backlog'. Signed-off-by: tlongwell-block <109685178+tlongwell-block@users.noreply.github.com>
1 parent b6dd829 commit ce34e5d

2 files changed

Lines changed: 64 additions & 0 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
import { Headphones } from "lucide-react";
2+
3+
/**
4+
* "Headphones recommended — echo cancellation lands in the next release."
5+
*
6+
* Session-dismissable banner shown on the active HuddleBar while the desktop
7+
* client lacks an echo-cancellation reference. The current play path is
8+
* native rodio (outside the WebView render graph), so the browser's WebRTC
9+
* AEC cannot suppress local-speaker → mic feedback for users on speakers.
10+
*
11+
* The follow-up PR moves remote-peer playout into WebAudio inside the same
12+
* `AudioContext` as `getUserMedia({ echoCancellation: true })`. When that
13+
* lands, the parent component flips `aecMissing` to false and this banner
14+
* (and this file) become removable in a single diff.
15+
*
16+
* State (`dismissed`, `aecMissing`) lives in the parent so the parent can
17+
* decide whether to render at all. Keep this component dumb so the
18+
* deletion later is mechanical.
19+
*/
20+
export function HeadphonesNotice({ onDismiss }: { onDismiss: () => void }) {
21+
return (
22+
<output
23+
data-testid="huddle-headphones-notice"
24+
className="flex items-center gap-1.5 rounded bg-amber-500/10 px-2 py-1 text-xs text-amber-700 dark:text-amber-300"
25+
>
26+
<Headphones className="h-3 w-3" />
27+
<span className="max-w-[260px] truncate">
28+
Headphones recommended — echo cancellation lands in the next release.
29+
</span>
30+
<button
31+
aria-label="Dismiss headphones notice for this session"
32+
className="ml-1 opacity-60 hover:opacity-100"
33+
onClick={onDismiss}
34+
type="button"
35+
>
36+
37+
</button>
38+
</output>
39+
);
40+
}

desktop/src/features/huddle/components/HuddleBar.tsx

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ import { Button } from "@/shared/ui/button";
88
import { Tooltip, TooltipContent, TooltipTrigger } from "@/shared/ui/tooltip";
99
import { useHuddle } from "../HuddleContext";
1010
import { AddAgentDialog, type AgentAddResult } from "./AddAgentDialog";
11+
import { HeadphonesNotice } from "./HeadphonesNotice";
1112
import { MicControls, SpeakerControls } from "./MicControls";
1213
import { ParticipantList } from "./ParticipantList";
1314

@@ -60,6 +61,16 @@ export function HuddleBar({ className }: HuddleBarProps) {
6061
const isPttMode = voiceInputMode === "push_to_talk";
6162
const [state, setState] = React.useState<HuddleState | null>(null);
6263
const [isMuted, setIsMuted] = React.useState(false);
64+
// Session-dismissable "use headphones" notice. The desktop client plays
65+
// remote peer audio via native rodio, outside the WebView render graph,
66+
// so the browser's echo canceller has no far-end reference to cancel
67+
// against. Until the WebAudio AEC follow-up PR lands, two people on
68+
// speakers in the same physical room will hear themselves echo. The
69+
// banner stays visible across huddle start/stop within a single page
70+
// load; it auto-removes when the AEC plumbing lands and the detection
71+
// below flips to false.
72+
const [headphonesNoticeDismissed, setHeadphonesNoticeDismissed] =
73+
React.useState(false);
6374
const ttsEnabled = state?.tts_enabled ?? true;
6475
const [isLeaving, setIsLeaving] = React.useState(false);
6576
const [showAddAgent, setShowAddAgent] = React.useState(false);
@@ -165,6 +176,12 @@ export function HuddleBar({ className }: HuddleBarProps) {
165176
if (!state || (state.phase !== "active" && state.phase !== "connected"))
166177
return null;
167178

179+
// Self-removing detection: remote-peer audio plays through native rodio
180+
// today (outside the WebView render graph), so the browser's AEC has no
181+
// far-end reference. The AEC follow-up PR flips this constant in the
182+
// same diff that moves playout into WebAudio.
183+
const aecMissing = true;
184+
168185
async function handleLeave() {
169186
if (isLeaving) return;
170187
setIsLeaving(true);
@@ -226,6 +243,13 @@ export function HuddleBar({ className }: HuddleBarProps) {
226243
</div>
227244
)}
228245

246+
{/* Echo-cancellation pre-PR notice. Removed when AEC plumbing lands. */}
247+
{aecMissing && !headphonesNoticeDismissed && (
248+
<HeadphonesNotice
249+
onDismiss={() => setHeadphonesNoticeDismissed(true)}
250+
/>
251+
)}
252+
229253
{/* Model download progress */}
230254
{modelStatus &&
231255
(modelStatus.stt !== "ready" || modelStatus.tts !== "ready") && (

0 commit comments

Comments
 (0)