Skip to content

Commit fea8943

Browse files
ralyodioclaude
andauthored
feat: AI call analysis on every surface (decided before the call) (#115)
Hosts switch on AI call analysis before a screen share or voice call on pairux.com, the desktop app or the mobile app; "Record the call" turns on with it. After the call they get a private feedback report. - Session settings carry `analysis` ({ enabled, kind, keepRecording }), set at creation only; Pro/Team gate in POST /api/sessions and the scheduled-meeting start route. - Capture: web and desktop upload the call's mixed audio (host + everyone) in 15s chunks plus a screen still every 20s; mobile records the host mic with expo-av in 5-minute segments. Chunks are grouped by recorder run so reloads and segments join cleanly. - Job: pg_cron -> /api/analyses/run claims one analysis, joins the audio (ffmpeg, now in the web image), transcribes with OpenAI gpt-4o-transcribe-diarize (whisper-1 fallback), measures talk share, pace, fillers, questions and interruptions, and has claude-opus-5 write a structured report (interview / team sync / presentation / general). Recording kept or deleted per the host's choice; host emailed. - Consent: every participant sees a recording notice on web, desktop and mobile for the whole call. - Reports at /analyses and /analyses/[id]; dashboard link; desktop links out. - Visible: home section, /features entry and /features/call-analysis, pricing (Pro/Team), AI note taker use case now available, sitemap, llms.txt. - Root @types/react pinned to 19 so adding expo-av cannot re-hoist React 18 types into the web app. Co-authored-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 5e2fb74 commit fea8943

64 files changed

Lines changed: 3751 additions & 54 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎apps/desktop/src/main/ipc/meetings.ts‎

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CallAnalysisSettings } from '@pairux/shared-types';
12
import { ipcMain } from 'electron';
23
import { apiFetch } from '../lib/apiFetch';
34
import { getValidAuth } from '../auth/secure-storage';
@@ -73,15 +74,19 @@ export function registerMeetingHandlers(): void {
7374
'meetings:start',
7475
async (
7576
_event,
76-
args: { scheduledSessionId: string }
77+
args: { scheduledSessionId: string; analysis?: CallAnalysisSettings }
7778
): Promise<StartMeetingResult | { success: false; error: string }> => {
7879
try {
7980
const headers = await getAuthHeaders();
8081
if (!headers) return { success: false, error: 'Not authenticated' };
8182

8283
const response = await apiFetch(
8384
`${API_BASE_URL}/api/scheduled-sessions/${args.scheduledSessionId}/start`,
84-
{ method: 'POST', headers }
85+
{
86+
method: 'POST',
87+
headers,
88+
body: JSON.stringify(args.analysis?.enabled ? { analysis: args.analysis } : {}),
89+
}
8590
);
8691

8792
const data = (await response.json()) as ApiResponse<{

‎apps/desktop/src/main/ipc/session.ts‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ export function registerSessionHandlers(): void {
5050
allowGuestControl: settings?.allowGuestControl ?? false,
5151
maxParticipants: settings?.maxParticipants ?? 5,
5252
mode: settings?.mode ?? 'p2p',
53+
...(settings?.analysis?.enabled ? { analysis: settings.analysis } : {}),
5354
}),
5455
});
5556

‎apps/desktop/src/preload/api.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { CallAnalysisSettings } from '@pairux/shared-types';
12
import type {
23
CaptureSource,
34
Plan,
@@ -72,6 +73,8 @@ export interface CreateSessionSettings {
7273
allowGuestControl?: boolean;
7374
maxParticipants?: number;
7475
mode?: SessionMode;
76+
/** AI call analysis, chosen before the call starts. */
77+
analysis?: CallAnalysisSettings;
7578
}
7679

7780
// Request/response channels (invoke pattern)
@@ -233,7 +236,7 @@ export interface IPCChannels {
233236
};
234237

235238
'meetings:start': {
236-
args: { scheduledSessionId: string };
239+
args: { scheduledSessionId: string; analysis?: CallAnalysisSettings };
237240
return: StartMeetingResult | { success: false; error: string };
238241
};
239242

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,108 @@
1+
import { Sparkles } from 'lucide-react';
2+
import type { CallAnalysisKind } from '@pairux/shared-types';
3+
import { useAnalysisPreference } from '@/lib/callAnalysisPreference';
4+
import { getElectronAPI } from '@/lib/ipc';
5+
6+
const KIND_LABELS: Record<CallAnalysisKind, string> = {
7+
general: 'General call',
8+
interview: 'Interview',
9+
'team-sync': 'Team sync',
10+
presentation: 'Presentation',
11+
};
12+
13+
/**
14+
* Home-screen switch for AI call analysis: decided before a call starts and
15+
* applied to the next session this app creates. Turning it on turns
16+
* "Record the call" on with it.
17+
*/
18+
export function CallAnalysisPanel({ disabled = false }: { disabled?: boolean }) {
19+
const [value, setValue] = useAnalysisPreference();
20+
21+
return (
22+
<div
23+
className="mb-6 rounded-lg border border-violet-500/30 bg-violet-500/10 p-4"
24+
data-testid="call-analysis-panel"
25+
>
26+
<div className="flex items-start justify-between gap-4">
27+
<div>
28+
<p className="flex items-center gap-2 text-sm font-semibold text-foreground">
29+
<Sparkles className="h-4 w-4 text-violet-400" />
30+
AI call analysis
31+
<span className="rounded-full bg-violet-600 px-1.5 py-0.5 text-[10px] font-bold uppercase text-white">
32+
Pro
33+
</span>
34+
</p>
35+
<p className="mt-1 text-xs text-muted-foreground">
36+
Feedback after the call on talk time, pace, filler words and what to practice. Applies
37+
to the next call you start; it cannot be switched on mid-call.{' '}
38+
<button
39+
type="button"
40+
className="text-violet-300 underline"
41+
onClick={() => void getElectronAPI().invoke('auth:openExternal', '/analyses')}
42+
>
43+
Your reports
44+
</button>
45+
</p>
46+
</div>
47+
<button
48+
type="button"
49+
role="switch"
50+
aria-checked={value.enabled}
51+
aria-label="Analyze the next call"
52+
disabled={disabled}
53+
onClick={() => {
54+
setValue(
55+
value.enabled
56+
? { ...value, enabled: false }
57+
: { ...value, enabled: true, keepRecording: true }
58+
);
59+
}}
60+
className={`relative inline-flex h-6 w-11 flex-shrink-0 rounded-full border-2 border-transparent transition-colors disabled:opacity-50 ${
61+
value.enabled ? 'bg-violet-600' : 'bg-muted'
62+
}`}
63+
>
64+
<span
65+
className={`inline-block h-5 w-5 transform rounded-full bg-white shadow transition-transform ${
66+
value.enabled ? 'translate-x-5' : 'translate-x-0'
67+
}`}
68+
/>
69+
</button>
70+
</div>
71+
{value.enabled && (
72+
<div className="mt-3 flex flex-wrap items-center gap-4 text-sm">
73+
<label className="flex items-center gap-2">
74+
Call type
75+
<select
76+
value={value.kind}
77+
disabled={disabled}
78+
onChange={(e) => {
79+
setValue({ ...value, kind: e.target.value as CallAnalysisKind });
80+
}}
81+
className="rounded-md border border-border bg-background px-2 py-1"
82+
>
83+
{(Object.keys(KIND_LABELS) as CallAnalysisKind[]).map((kind) => (
84+
<option key={kind} value={kind}>
85+
{KIND_LABELS[kind]}
86+
</option>
87+
))}
88+
</select>
89+
</label>
90+
<label className="flex items-center gap-2">
91+
<input
92+
type="checkbox"
93+
checked={value.keepRecording}
94+
disabled={disabled}
95+
onChange={(e) => {
96+
setValue({ ...value, keepRecording: e.target.checked });
97+
}}
98+
/>
99+
Record the call and keep the recording
100+
</label>
101+
<span className="text-xs text-muted-foreground">
102+
Everyone who joins is told the call is recorded for AI analysis.
103+
</span>
104+
</div>
105+
)}
106+
</div>
107+
);
108+
}

‎apps/desktop/src/renderer/components/CreateLinkModal.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import { getElectronAPI } from '@/lib/ipc';
77
import type { Session, SessionMode } from '@pairux/shared-types';
88
import { APP_URL } from '../../shared/config';
99
import { getDefaultAllowGuestControl, getDefaultSessionMode } from '@/lib/sessionDefaults';
10+
import { analysisForNewCall } from '@/lib/callAnalysisPreference';
1011

1112
interface CreateLinkModalProps {
1213
isOpen: boolean;
@@ -27,10 +28,12 @@ export function CreateLinkModal({ isOpen, onClose, onStartSharing }: CreateLinkM
2728

2829
try {
2930
const api = getElectronAPI();
31+
const analysis = analysisForNewCall();
3032
const result = await api.invoke('session:create', {
3133
allowGuestControl: getDefaultAllowGuestControl(),
3234
maxParticipants: mode === 'sfu' ? 10 : 5,
3335
mode,
36+
...(analysis ? { analysis } : {}),
3437
});
3538

3639
if (!result.success) {
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import { Sparkles } from 'lucide-react';
2+
import type { SessionSettings } from '@pairux/shared-types';
3+
4+
/**
5+
* Told to everyone in a call whose host turned on AI call analysis. Stays up
6+
* for the whole call: consent to being recorded has to remain visible.
7+
* (Web twin: apps/web/src/components/session/RecordingNotice.tsx.)
8+
*/
9+
export function RecordingNotice({ settings }: { settings: SessionSettings | null | undefined }) {
10+
const analysis = settings?.analysis;
11+
if (analysis?.enabled !== true) return null;
12+
return (
13+
<div
14+
role="status"
15+
data-testid="recording-notice"
16+
className="flex items-center gap-2 rounded-lg bg-violet-600/20 px-3 py-2 text-xs text-violet-200"
17+
>
18+
<Sparkles className="h-3.5 w-3.5 flex-shrink-0" />
19+
This call is being recorded for AI analysis by the host
20+
{analysis.keepRecording ? ', who will keep the recording' : ''}. Leave the call if you do not
21+
want to take part.
22+
</div>
23+
);
24+
}

‎apps/desktop/src/renderer/components/StartMeetingModal.tsx‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
} from '@/lib/meetingTiming';
1515
import type { ScheduledMeeting } from '../../preload/api';
1616
import type { Session } from '@pairux/shared-types';
17+
import { analysisForNewCall } from '@/lib/callAnalysisPreference';
1718

1819
interface Props {
1920
isOpen: boolean;
@@ -75,8 +76,10 @@ export function StartMeetingModal({ isOpen, onClose, onStarted }: Props) {
7576
setStartingId(meeting.id);
7677
setError(null);
7778
try {
79+
const analysis = analysisForNewCall();
7880
const result = await getElectronAPI().invoke('meetings:start', {
7981
scheduledSessionId: meeting.id,
82+
...(analysis ? { analysis } : {}),
8083
});
8184
if (!result.success) {
8285
setError(result.error);

‎apps/desktop/src/renderer/components/capture/CapturePreview.tsx‎

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import {
3434
clampAudioGain,
3535
} from '@pairux/shared-types';
3636
import { APP_URL, API_BASE_URL } from '../../../shared/config';
37+
import { CallAnalysisCapture, type CaptureStatus } from '@/lib/callAnalysisCapture';
3738
import { getElectronAPI } from '@/lib/ipc';
3839
import { Button } from '@/components/ui/button';
3940
import { ChatPanel } from '@/components/chat';
@@ -1240,6 +1241,52 @@ export function CapturePreview({
12401241
presentationVideoTrack,
12411242
]);
12421243

1244+
// AI call analysis, chosen on the home screen before this call started:
1245+
// upload the call's mixed audio (host + every viewer) and screen stills for
1246+
// the report. isHosting flaps during reconnects, so the capture only ends
1247+
// when this screen goes away; the server also closes it when the session
1248+
// ends or its uploads stop.
1249+
const analysisSettings = session?.settings.analysis;
1250+
const analysisCaptureRef = useRef<CallAnalysisCapture | null>(null);
1251+
const [analysisStatus, setAnalysisStatus] = useState<CaptureStatus | 'off'>('off');
1252+
useEffect(() => {
1253+
if (!analysisSettings?.enabled || !session || !isHosting || analysisCaptureRef.current) return;
1254+
const audio = mixedStream ?? hostMicStream;
1255+
if (!audio) return;
1256+
const capture = new CallAnalysisCapture({
1257+
apiBase: API_BASE_URL,
1258+
sessionId: session.id,
1259+
source: 'desktop',
1260+
headers: (): Record<string, string> => {
1261+
const auth = getAuthHeaders().Authorization;
1262+
return auth ? { Authorization: auth } : {};
1263+
},
1264+
onStatus: setAnalysisStatus,
1265+
});
1266+
analysisCaptureRef.current = capture;
1267+
void capture.start(audio);
1268+
}, [analysisSettings?.enabled, session, isHosting, mixedStream, hostMicStream, getAuthHeaders]);
1269+
useEffect(() => {
1270+
analysisCaptureRef.current?.setVideo(stream);
1271+
}, [stream, analysisStatus]);
1272+
useEffect(() => {
1273+
return () => {
1274+
void analysisCaptureRef.current?.finish();
1275+
};
1276+
}, []);
1277+
1278+
// "Record the call" is on by default with analysis: start the full local
1279+
// recording when sharing starts. Once per shared stream, so stopping it by
1280+
// hand sticks.
1281+
const autoRecordedStreamRef = useRef<MediaStream | null>(null);
1282+
useEffect(() => {
1283+
if (!analysisSettings?.enabled || !analysisSettings.keepRecording) return;
1284+
if (!stream || !source || isRecording) return;
1285+
if (autoRecordedStreamRef.current === stream) return;
1286+
autoRecordedStreamRef.current = stream;
1287+
void handleStartRecording();
1288+
}, [analysisSettings, stream, source, isRecording, handleStartRecording]);
1289+
12431290
const handleStopRecording = useCallback(async () => {
12441291
await stopRecording();
12451292
setSpaceWarning(null);
@@ -1831,6 +1878,20 @@ export function CapturePreview({
18311878
)}
18321879
</div>
18331880

1881+
{/* AI call analysis indicator */}
1882+
{analysisStatus !== 'off' && (
1883+
<div
1884+
className="absolute left-4 top-4 rounded-full bg-violet-600/90 px-3 py-1 text-xs font-medium text-white"
1885+
data-testid="analysis-badge"
1886+
>
1887+
{analysisStatus === 'error'
1888+
? 'AI analysis: not recording'
1889+
: analysisStatus === 'capturing'
1890+
? 'AI analysis: recording'
1891+
: 'AI analysis on'}
1892+
</div>
1893+
)}
1894+
18341895
{/* Recording indicator */}
18351896
{isRecording && (
18361897
<div className="absolute right-4 top-4">

‎apps/desktop/src/renderer/hooks/useSession.ts‎

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useState, useCallback } from 'react';
22
import type { Session, SessionParticipant, SessionMode } from '@pairux/shared-types';
33
import { getElectronAPI } from '@/lib/ipc';
4+
import { analysisForNewCall } from '@/lib/callAnalysisPreference';
45

56
interface SessionState {
67
session: Session | null;
@@ -40,7 +41,12 @@ export function useSession(): UseSessionReturn {
4041

4142
try {
4243
const api = getElectronAPI();
43-
const result = await api.invoke('session:create', settings);
44+
// The home-screen AI analysis choice rides along with every new session.
45+
const analysis = analysisForNewCall();
46+
const result = await api.invoke('session:create', {
47+
...settings,
48+
...(analysis ? { analysis } : {}),
49+
});
4450

4551
if (!result.success) {
4652
throw new Error(result.error);

0 commit comments

Comments
 (0)