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
89 changes: 69 additions & 20 deletions app/e2e-mock-ipc.js
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,25 @@ const SPEAKER_SEED_MEETING = {
discussion_areas: [],
};

// The sidecar can retain several clusters even where the transcript correctly
// keeps its legacy non-diarised labels. This test-only mode exercises that
// distinction with the same multi-cluster payload as the normal speaker seed.
const seededSpeakerMeeting = () =>
process.env.STENOAI_E2E_SEED_SPEAKER_SIDECAR === '1'
? { ...SPEAKER_SEED_MEETING, is_diarised: false }
: SPEAKER_SEED_MEETING;

const MANY_PERSON_PROFILES = [
{ person_id: 'p-alex', display_name: 'Alex Morgan', prototype_counts: { remote: 1 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-bao', display_name: 'Bao Nguyen', prototype_counts: { in_person: 2 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-daria', display_name: 'Daria Novak', prototype_counts: { remote: 1 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-emil', display_name: 'Emil Fischer', prototype_counts: { in_person: 1 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-fatima', display_name: 'Fatima Rahman', prototype_counts: { remote: 2 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-greta', display_name: 'Greta Silva', prototype_counts: { in_person: 1 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-hugo', display_name: 'Hugo Costa', prototype_counts: { remote: 1 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-zora', display_name: 'Zora Quinn', prototype_counts: {}, hard_negative_counts: {}, updated_at: 0 },
];

/**
* Carried-over segments for the resume/continue case, keyed off
* STENOAI_E2E_SEED_PRIOR_SEGMENTS: `1` is one earlier recording, `twice` is a
Expand Down Expand Up @@ -309,12 +328,17 @@ function install({ ipcMain }) {
// "6b. Speakers" section). Mutated by confirm/create/rename/delete so a
// spec can click a real action and assert the panel re-renders from the
// (mocked) refetch, the same way org-login/org-status do for org state.
const seedSpeakers = process.env.STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS === '1';
const seedSpeakers =
process.env.STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS === '1'
|| process.env.STENOAI_E2E_SEED_SPEAKER_SIDECAR === '1'
|| process.env.STENOAI_E2E_SEED_SPEAKER_SINGLE_CLUSTER === '1'
|| process.env.STENOAI_E2E_SEED_MANY_PEOPLE === '1';
const speakerState = {
personProfiles: seedSpeakers
? [
{ person_id: 'p-alpha', display_name: 'Person Alpha', prototype_counts: { remote: 2 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-beta', display_name: 'Person Beta', prototype_counts: { remote: 1 }, hard_negative_counts: {}, updated_at: 0 },
{ person_id: 'p-alpha', display_name: 'Person Alpha', prototype_counts: { remote: 2 }, hard_negative_counts: {}, sample_available: true, updated_at: 0 },
{ person_id: 'p-beta', display_name: 'Person Beta', prototype_counts: { remote: 1 }, hard_negative_counts: {}, sample_available: false, updated_at: 0 },
...(process.env.STENOAI_E2E_SEED_MANY_PEOPLE === '1' ? MANY_PERSON_PROFILES : []),
]
: [],
// channels -> { diarization_speaker_id: SpeakerSuggestion }
Expand Down Expand Up @@ -405,6 +429,14 @@ function install({ ipcMain }) {
: {},
};

const speakerSuggestionsForResponse = () => {
if (process.env.STENOAI_E2E_SEED_SPEAKER_SINGLE_CLUSTER !== '1') {
return speakerState.suggestions;
}
const firstCluster = speakerState.suggestions.mic?.SPEAKER_0;
return firstCluster ? { mic: { SPEAKER_0: firstCluster } } : {};
};

// Mirrors Config._person_name_taken's case/whitespace-insensitive
// uniqueness check (src/config.py) -- keeps the mock's error path
// consistent with the real backend for the T1 duplicate-name tests.
Expand Down Expand Up @@ -591,7 +623,7 @@ function install({ ipcMain }) {
return { success: true, meetings: [PROCESSING_MEETING] };
}
if (seedSpeakers) {
return { success: true, meetings: [SPEAKER_SEED_MEETING] };
return { success: true, meetings: [seededSpeakerMeeting()] };
}
if (process.env.STENOAI_E2E_SEED_MEETING === '1') {
return { success: true, meetings: [seededMeeting()] };
Expand Down Expand Up @@ -630,7 +662,7 @@ function install({ ipcMain }) {
return { success: true, meeting: applyOverlay(PROCESSING_MEETING) };
}
if (seedSpeakers) {
return { success: true, meeting: SPEAKER_SEED_MEETING };
return { success: true, meeting: seededSpeakerMeeting() };
}
if (process.env.STENOAI_E2E_SEED_MEETING === '1') {
// seededMeeting() carries main's optional template-report; applyOverlay
Expand Down Expand Up @@ -829,21 +861,24 @@ function install({ ipcMain }) {

'list-person-profiles': async () => ({ success: true, person_profiles: speakerState.personProfiles }),

'suggest-speakers': async (_event, meetingStem) => ({
success: true,
meeting_id: meetingStem,
recording_available: seedSpeakers,
// Same derivation as the real minimum_speaker_count: every cluster is
// at least one person, every cluster marked as mixed is at least two.
minimum_speaker_count: Object.values(speakerState.suggestions).reduce(
(sum, clusters) =>
sum
+ Object.keys(clusters).length
+ Object.values(clusters).filter((c) => c.contains_multiple_speakers).length,
0,
),
channels: speakerState.suggestions,
}),
'suggest-speakers': async (_event, meetingStem) => {
const channels = speakerSuggestionsForResponse();
return {
success: true,
meeting_id: meetingStem,
recording_available: seedSpeakers,
// Same derivation as the real minimum_speaker_count: every cluster is
// at least one person, every cluster marked as mixed is at least two.
minimum_speaker_count: Object.values(channels).reduce(
(sum, clusters) =>
sum
+ Object.keys(clusters).length
+ Object.values(clusters).filter((c) => c.contains_multiple_speakers).length,
0,
),
channels,
};
},

// Marks/clears "this cluster holds more than one person". Mirrors the
// real CLI's effect on a later suggest-speakers refetch, which is what
Expand Down Expand Up @@ -950,6 +985,17 @@ function install({ ipcMain }) {
return { success: true, audio_base64: MINIMAL_WAV_BASE64 };
},

'get-person-sample-audio': async (_event, personId) => {
const profile = speakerState.personProfiles.find((person) => person.person_id === personId);
if (!profile?.sample_available) {
return { success: false, error: 'voice sample unavailable' };
}
if (process.env.STENOAI_E2E_PERSON_SAMPLE_FAIL === '1') {
return { success: false, error: 'simulated private backend detail' };
}
return { success: true, audio_base64: MINIMAL_WAV_BASE64 };
},

// Accepts either --person-id (Change) or --new-person (New person) mode,
// mirroring the real CLI's exactly-one-of contract. Mutates
// speakerState so a subsequent suggest-speakers refetch (the panel's
Expand Down Expand Up @@ -1056,6 +1102,9 @@ function install({ ipcMain }) {
// confirmed_by_user from person_profiles on every call, so a deleted
// person's references disappear from any cluster that pointed at them.
'delete-person-profile': async (_event, id) => {
if (process.env.STENOAI_E2E_DELETE_PERSON_FAIL === '1') {
return { success: false, error: 'simulated delete failure' };
}
const before = speakerState.personProfiles.length;
speakerState.personProfiles = speakerState.personProfiles.filter((p) => p.person_id !== id);
const deleted = speakerState.personProfiles.length < before;
Expand Down
14 changes: 13 additions & 1 deletion app/main.js
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ const { createDebugLog } = require('./debug-log');
const { createTeardownRegistry } = require('./teardown');
const { registerFoldersIpc } = require('./folders-ipc');
const { registerSettingsIpc } = require('./settings-ipc');
const { registerPersonSampleIpc } = require('./person-sample-ipc');
const { registerObsidianSync } = require('./obsidian-sync');
const { registerObsidianIpc } = require('./obsidian-ipc');
const { isSafeToAutoInstall } = require('./update-idle-gate');
Expand Down Expand Up @@ -7970,8 +7971,18 @@ ipcMain.handle('list-person-profiles', async () => {
});

ipcMain.handle('suggest-speakers', async (_e, meetingStem) => {
const safeStem = path.basename(String(meetingStem || ''));
if (!safeStem) {
return {
success: true,
meeting_id: safeStem,
recording_available: false,
minimum_speaker_count: 0,
channels: {},
};
}
try {
const out = await runPythonScript('simple_recorder.py', ['suggest-speakers', meetingStem]);
const out = await runPythonScript('simple_recorder.py', ['suggest-speakers', safeStem]);
return JSON.parse(out);
} catch (error) {
return { success: false, error: error.message };
Expand Down Expand Up @@ -8348,6 +8359,7 @@ ipcMain.handle('pull-parakeet-model', async (event, modelId) => {
// handlers coupled to another domain (telemetry, models, mic-monitor, calendar,
// tray) deliberately stay in main.js until that domain's own extraction.
registerSettingsIpc({ ipcMain, runPythonScript, sendDebugLog });
registerPersonSampleIpc({ ipcMain, runPythonScript });

// Fired by the renderer's silence detector. The renderer has already
// asked main to stop the recording via pause/stop; this just surfaces
Expand Down
2 changes: 1 addition & 1 deletion app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@
"typecheck:renderer": "tsc -p renderer/tsconfig.json --noEmit",
"lint:renderer": "eslint --config renderer/eslint.config.mjs renderer/src",
"format:renderer": "prettier --write renderer/src",
"test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js update-error-copy.test.js notification-copy.test.js obsidian-sync.test.js && vitest run",
"test:unit": "node --test processing-log.test.js meeting-detect.test.js notes-file.test.js backend-stream.test.js ipc-contract.test.js person-sample-ipc.test.js shortcut-url.test.js setup-check-parse.test.js diagnostics-forward.test.js analytics-helpers.test.js live-snapshot-sweep.test.js backend-cli.test.js debug-log.test.js teardown.test.js folders-ipc.test.js settings-ipc.test.js regen-title-busy-guard.test.js update-idle-gate.test.js update-os-gate.test.js update-error-copy.test.js notification-copy.test.js obsidian-sync.test.js && vitest run",
"build": "npm run build:renderer && electron-builder",
"pack:unsigned": "npm run build:renderer && electron-builder --dir --config electron-builder.ci.yml",
"build-mac": "npm run build:renderer && electron-builder --mac",
Expand Down
22 changes: 22 additions & 0 deletions app/person-sample-ipc.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
'use strict';

const PERSON_SAMPLE_UNAVAILABLE = {
success: false,
error: 'voice sample unavailable',
};

function registerPersonSampleIpc({ ipcMain, runPythonScript }) {
ipcMain.handle('get-person-sample-audio', async (_event, personId) => {
try {
const out = await runPythonScript('simple_recorder.py', [
'get-person-sample-audio',
personId,
]);
return JSON.parse(out);
} catch {
return { ...PERSON_SAMPLE_UNAVAILABLE };
}
});
}

module.exports = { registerPersonSampleIpc };
50 changes: 50 additions & 0 deletions app/person-sample-ipc.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
'use strict';

const { test } = require('node:test');
const assert = require('node:assert');
const { registerPersonSampleIpc } = require('./person-sample-ipc');

function harness(runPythonScript) {
const handlers = {};
registerPersonSampleIpc({
ipcMain: {
handle: (channel, handler) => {
handlers[channel] = handler;
},
},
runPythonScript,
});
return handlers;
}

test('person sample IPC forwards only the person id and returns parsed audio', async () => {
const calls = [];
const handlers = harness(async (script, args) => {
calls.push({ script, args });
return '{"success":true,"audio_base64":"UklGRg=="}';
});

const result = await handlers['get-person-sample-audio']({}, 'person-1');

assert.deepStrictEqual(calls, [{
script: 'simple_recorder.py',
args: ['get-person-sample-audio', 'person-1'],
}]);
assert.deepStrictEqual(result, { success: true, audio_base64: 'UklGRg==' });
});

test('person sample IPC replaces backend crashes and malformed output with a fixed error', async () => {
for (const runPythonScript of [
async () => { throw new Error('/private/user/path\nTraceback: private detail'); },
async () => 'not json',
]) {
const handlers = harness(runPythonScript);

const result = await handlers['get-person-sample-audio']({}, 'person-1');

assert.deepStrictEqual(result, {
success: false,
error: 'voice sample unavailable',
});
}
});
1 change: 1 addition & 0 deletions app/preload.js
Original file line number Diff line number Diff line change
Expand Up @@ -208,6 +208,7 @@ const stenoai = {
deleteProfile: (id) => invoke('delete-person-profile', id),
getSampleAudio: (meetingStem, channel, diarizationSpeakerId, segmentIndex) =>
invoke('get-speaker-sample-audio', meetingStem, channel, diarizationSpeakerId, segmentIndex),
getPersonSampleAudio: (id) => invoke('get-person-sample-audio', id),
markCluster: (params) => invoke('mark-speaker-cluster', params),
setClusterReviewState: (params) => invoke('set-cluster-review-state', params),
namingStatus: (meetingStem) => invoke('speaker-naming-status', meetingStem),
Expand Down
Loading
Loading