Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 14 additions & 0 deletions src/main/store/config.store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
28 changes: 25 additions & 3 deletions src/renderer/components/custom/control-panel/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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();

Expand Down Expand Up @@ -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
Expand All @@ -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, StateConfig> = {
[RunningState.Idle]: {
onClick: handleStartClick,
Expand Down Expand Up @@ -213,6 +229,12 @@ export default function ControlPanel() {
</div>
</div>

<HeadphoneNoticeDialog
open={headphoneNoticeOpen}
onOpenChange={setHeadphoneNoticeOpen}
onProceed={() => void startAfterNotice()}
/>

{isMac && (
<PermissionGateDialog
open={permGateOpen}
Expand Down
115 changes: 115 additions & 0 deletions src/renderer/components/custom/headphone-notice-dialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { Headphones, MicOff, Volume2 } from 'lucide-react';
import { useEffect, useState } from 'react';

import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useConfigStore } from '@/hooks/use-config-store';

interface HeadphoneNoticeDialogProps {
open: boolean;
onOpenChange: (open: boolean) => 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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-sm">
<DialogHeader>
<DialogTitle className="flex items-center gap-2">
<Headphones className="h-4 w-4" />
Put your headphones on
</DialogTitle>
<DialogDescription>
This session needs the interviewer&apos;s voice going to your ears only.
</DialogDescription>
</DialogHeader>

<div className="space-y-3 py-1">
<NoticeRow
icon={<Volume2 className="h-4 w-4" />}
text="On speakers, your microphone hears the interviewer as well as you do."
/>
<NoticeRow
icon={<MicOff className="h-4 w-4" />}
// 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."
/>
</div>

<label className="flex items-center gap-2 text-xs text-muted-foreground cursor-pointer">
<Checkbox
checked={dontShowAgain}
onCheckedChange={(checked) => setDontShowAgain(checked === true)}
/>
Do not show this again
</label>

<DialogFooter className="gap-2 sm:gap-0">
<Button variant="ghost" size="sm" onClick={() => onOpenChange(false)}>
Cancel
</Button>
<Button size="sm" onClick={handleProceed}>
My headphones are on
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

function NoticeRow({ icon, text }: { icon: React.ReactNode; text: string }) {
return (
<div className="flex items-start gap-3">
<div className="mt-0.5 text-muted-foreground">{icon}</div>
<p className="flex-1 text-xs text-muted-foreground">{text}</p>
</div>
);
}
3 changes: 3 additions & 0 deletions src/renderer/types/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
8 changes: 8 additions & 0 deletions test/config-store.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down