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); + + // 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. + 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();