From 40ef833579673057211b1cacf7e8f410697a6ae0 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 22:08:10 -0400 Subject: [PATCH 1/2] feat: require headphones before a session starts `ch_0` is a loopback of the render endpoint, so on speakers the microphone hears the interviewer too and the question lands as a recent `Self` final. `skipDueToRecentSelf` then suppresses the live suggestion for the question that was just asked, silently. The notice is shown before every session until the user silences it, and it names that failure rather than recommending headphones for "best results". It sits ahead of the macOS permission gate and ahead of `doStart`: on speakers the echo is in the audio before the first question. Nothing in the renderer can detect the output route, so the dialog asks rather than guesses. The preference is written when the user goes through, not when they tick the box, so a tick followed by Cancel does not silence a warning they never acted on. Closes #116 Co-Authored-By: Claude Opus 5 --- CLAUDE.md | 31 +++++ src/main/store/config.store.ts | 14 +++ .../components/custom/control-panel/index.tsx | 28 ++++- .../custom/headphone-notice-dialog.tsx | 108 ++++++++++++++++++ src/renderer/types/config.ts | 3 + test/config-store.test.mjs | 8 ++ 6 files changed, 189 insertions(+), 3 deletions(-) create mode 100644 src/renderer/components/custom/headphone-notice-dialog.tsx diff --git a/CLAUDE.md b/CLAUDE.md index 92612379..614beed3 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -222,6 +222,37 @@ The app's own chrome is **not** localised, deliberately: an English button on a **The exported report is the one exception**, because it is the one artifact that leaves the machine and is handed to someone who was not there. The summarize prompt translates the headings *it* writes; the five words the client wraps around them - Transcripts, Suggestions, Suggestion, Interviewer, Date/Time - live in [export-labels.ts](src/main/utils/export-labels.ts) and follow the same setting, or the export is the half-translated document that prompt exists to avoid. The candidate is named rather than labelled, and timestamps stay on the machine's locale. `test/tools-export.test.mjs` pins that every enum member has a full set and that an unknown code falls back to English rather than throwing. +### Headphones + +`ch_0` is a loopback of the system's render endpoint, which is the same sound the speakers are +playing. So on speakers the microphone hears the interviewer a fraction of a second after the +loopback does, and the same words arrive on **both** channels. The transcript duplicates, which is +visible; the damaging half is not. The echo lands as a recent `Self` final, so +`skipDueToRecentSelf` in [transcript.service.ts](src/main/services/transcript.service.ts) +suppresses the live suggestion **for the question that was just asked**, with no error anywhere. +See #111 for the measurements and the longer-term suppression work. + +[headphone-notice-dialog.tsx](src/renderer/components/custom/headphone-notice-dialog.tsx) is shown +before every session until the user silences it, and it says what actually goes wrong rather than +recommending headphones for "best results" - the cost of ignoring it is answers that never appear. + +**Nothing here detects the output route, and the dialog does not pretend to.** +`enumerateDevices()` reports what exists, not what the sound is coming out of, and a label match on +"headset" would be wrong in both directions: it would clear a user whose headphones are plugged in +but not selected, and nag one whose USB interface is named after a mixer. The user's answer is the +only signal available, so it is asked for. + +It is the first thing Start asks, ahead of the save prompt and the macOS permission gate, because +on speakers the echo is already in the audio before the first question and because it is the +cheapest of the three to back out of - cancelling here means the other two were never asked. +`startAfterNotice` holds everything after it so the dialog can hand the start back without +duplicating those checks. + +`headphoneNoticeAcknowledged` is opt-out rather than opt-in: whether the call is on speakers is a +property of the machine and the meeting, not a setting, so it can change between sessions on the +same install. The preference is written when the user goes through, not when they tick the box - a +tick followed by Cancel would otherwise silence a warning they never acted on. + ### Audio input device **The microphone can be changed mid-interview**, and for the same reason the language can: the case diff --git a/src/main/store/config.store.ts b/src/main/store/config.store.ts index 92816bac..10c2ccc6 100644 --- a/src/main/store/config.store.ts +++ b/src/main/store/config.store.ts @@ -34,6 +34,15 @@ export interface RuntimeConfig { // suggestions come back as headline + keyword bullets instead of full sentences professionalMode: boolean; + + /** + * The user has ticked "do not show this again" on the headphone notice. + * + * Opt-out rather than opt-in: whether the call is coming out of speakers is a property of the + * machine and the meeting, not a setting this app can read, so the only reliable signal is + * the user's own answer and it is asked for again on every session until they silence it. + */ + headphoneNoticeAcknowledged: boolean; } // Default runtime configuration @@ -57,6 +66,8 @@ const DEFAULT_RUNTIME_CONFIG: RuntimeConfig = { // opt-in: prose is what every existing user already expects from the panel professionalMode: false, + + headphoneNoticeAcknowledged: false, }; // interviewConf (full name, profile, context) used to be cached under `runtime`, but it's now @@ -255,6 +266,9 @@ export const configStore = new ConfigStore(); if (raw?.professionalMode === undefined) { migration.professionalMode = false; } + if (raw?.headphoneNoticeAcknowledged === undefined) { + migration.headphoneNoticeAcknowledged = false; + } // perform migration only if there are values to set if (Object.keys(migration).length > 0) { configStore.updateConfig(migration); diff --git a/src/renderer/components/custom/control-panel/index.tsx b/src/renderer/components/custom/control-panel/index.tsx index 67ea216a..cea314b1 100644 --- a/src/renderer/components/custom/control-panel/index.tsx +++ b/src/renderer/components/custom/control-panel/index.tsx @@ -13,6 +13,7 @@ import { isMac } from '@/lib/consts'; import { getElectron } from '@/lib/utils'; import { RunningState } from '@/types/app-state'; +import HeadphoneNoticeDialog from '../headphone-notice-dialog'; import PermissionGateDialog from '../permission-gate-dialog'; import ZoomControl from '../zoom-control'; import { AudioGroup } from './audio-group'; @@ -37,6 +38,7 @@ export default function ControlPanel() { const { openConfigurationDialog } = useConfigurationDialog(); const { confirmDiscard } = useSaveHistoryGuard(); const [permGateOpen, setPermGateOpen] = useState(false); + const [headphoneNoticeOpen, setHeadphoneNoticeOpen] = useState(false); const { devices: audioInputDevices, ready: audioDevicesReady } = useAudioInputDevices(); @@ -110,9 +112,9 @@ export default function ControlPanel() { } }; - const handleStartClick = async () => { - if (!checkCanStart()) return; - + // Everything after the headphone notice. Split out so the notice can hand the start back once + // the user acknowledges it, without duplicating what follows. + const startAfterNotice = async () => { // `startAssistant` opens with `clearAll()`, so the previous interview is gone the moment // this goes ahead. Asked before the permission gate rather than after: a user who is about // to be sent into System Settings should not have answered a question first that the trip @@ -137,6 +139,20 @@ export default function ControlPanel() { await doStart(); }; + const handleStartClick = async () => { + if (!checkCanStart()) return; + + // Before the permission gate, and before anything opens a socket: on speakers the echo is + // already in the audio by the time the first question is asked, and the failure it causes + // is silent. Nothing here can detect the output route, so the user is asked. + if (!config?.headphoneNoticeAcknowledged) { + setHeadphoneNoticeOpen(true); + return; + } + + await startAfterNotice(); + }; + const stateConfig: Record = { [RunningState.Idle]: { onClick: handleStartClick, @@ -213,6 +229,12 @@ export default function ControlPanel() { + void startAfterNotice()} + /> + {isMac && ( void; + onProceed: () => void; +} + +/** + * Ask for headphones before the session opens. + * + * The app captures the interviewer through a loopback of the system's render endpoint, so on + * speakers the microphone hears the same words a fraction of a second later. What that costs is + * not cosmetic: the echo arrives as a `Self` final, and `skipDueToRecentSelf` in + * `transcript.service.ts` then suppresses the live suggestion for the question that was just + * asked. Silently, at the moment the candidate needs it. See #111. + * + * There is no reliable way to detect this from the renderer - `enumerateDevices()` reports what + * exists, not what the sound is coming out of - so the user's own answer is the only signal + * available, and it is asked for rather than guessed at. + */ +export default function HeadphoneNoticeDialog({ + open, + onOpenChange, + onProceed, +}: HeadphoneNoticeDialogProps) { + const { config, updateConfig } = useConfigStore(); + const [dontShowAgain, setDontShowAgain] = useState(false); + + const handleProceed = () => { + // Persisted on the way through rather than on the tick, so a user who changes their mind and + // cancels has not already silenced a warning they never acted on. + if (dontShowAgain && !config?.headphoneNoticeAcknowledged) { + updateConfig({ headphoneNoticeAcknowledged: true }).catch((e) => + console.error('Failed to persist the headphone notice preference', e) + ); + } + onOpenChange(false); + onProceed(); + }; + + return ( + + + + + + Put your headphones on + + + This session needs the interviewer's voice going to your ears only. + + + +
+ } + text="On speakers, your microphone hears the interviewer as well as you do." + /> + } + // The failure is the quiet one, so it is named rather than left as "quality issues". + text="The app then reads their question as something you said, and stops answering it - with no error to tell you why." + /> +
+ + + + + + + +
+
+ ); +} + +function NoticeRow({ icon, text }: { icon: React.ReactNode; text: string }) { + return ( +
+
{icon}
+

{text}

+
+ ); +} diff --git a/src/renderer/types/config.ts b/src/renderer/types/config.ts index 3a279a37..64c05e00 100644 --- a/src/renderer/types/config.ts +++ b/src/renderer/types/config.ts @@ -31,4 +31,7 @@ export interface Config { // Suggestions come back as headline + keyword bullets instead of full sentences professionalMode: boolean; + + // The user has silenced the headphone notice shown before each session starts + headphoneNoticeAcknowledged: boolean; } diff --git a/test/config-store.test.mjs b/test/config-store.test.mjs index 61d1e9e4..f40db7a4 100644 --- a/test/config-store.test.mjs +++ b/test/config-store.test.mjs @@ -40,6 +40,14 @@ export async function run(userDataDir) { cfg.email === 'a@b.c' && cfg.autoScrollTranscript === false ); + // Backfilled by the migration IIFE at the bottom of the store, which runs on import. A key + // that arrives undefined reads as "not acknowledged" either way, but the notice this one + // gates is shown on every Start until it is set, so an absent key must not read as silenced. + check( + 'a store written before the headphone notice existed defaults to showing it', + cfg.headphoneNoticeAcknowledged === false + ); + // The data-loss trap: an unrelated write must not drop the not-yet-migrated copy. store.configStore.updateConfig({ sessionToken: 'tok' }); const afterWrite = store.configStore.getStoredRuntime(); From 4baaa5e11db96470191edf32933aaefbbd9c4054 Mon Sep 17 00:00:00 2001 From: alpha Date: Thu, 27 Aug 2026 22:30:54 -0400 Subject: [PATCH 2/2] fix: clear the do-not-show tick when the headphone notice reopens The dialog is mounted for the life of the control panel, so a tick that was followed by Cancel survived and was waiting, already checked, the next time it opened - silencing the warning on a click the user did not knowingly make. Co-Authored-By: Claude Opus 5 --- .../components/custom/headphone-notice-dialog.tsx | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/components/custom/headphone-notice-dialog.tsx b/src/renderer/components/custom/headphone-notice-dialog.tsx index f1c56a49..6a08fb05 100644 --- a/src/renderer/components/custom/headphone-notice-dialog.tsx +++ b/src/renderer/components/custom/headphone-notice-dialog.tsx @@ -1,5 +1,5 @@ import { Headphones, MicOff, Volume2 } from 'lucide-react'; -import { useState } from 'react'; +import { useEffect, useState } from 'react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; @@ -40,6 +40,13 @@ export default function HeadphoneNoticeDialog({ const { config, updateConfig } = useConfigStore(); const [dontShowAgain, setDontShowAgain] = useState(false); + // The dialog is mounted for the life of the control panel, so the tick would otherwise + // survive a Cancel and be waiting - already checked - the next time it opens. Silencing a + // warning is then one click the user did not knowingly make. + useEffect(() => { + if (open) setDontShowAgain(false); + }, [open]); + const handleProceed = () => { // Persisted on the way through rather than on the tick, so a user who changes their mind and // cancels has not already silenced a warning they never acted on.