From 88e96c59c49a184614a7f8cc050cb81ae0c31437 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Fri, 7 Aug 2026 05:08:11 +0200 Subject: [PATCH 1/2] feat(speakers): add people management and sample playback --- app/e2e-mock-ipc.js | 89 +++- app/main.js | 14 +- app/package.json | 2 +- app/person-sample-ipc.js | 22 + app/person-sample-ipc.test.js | 50 +++ app/preload.js | 1 + .../src/components/SpeakerReviewPanel.tsx | 286 ++++++++----- .../components/speakerReviewOrdering.test.ts | 70 ++- .../src/components/speakerReviewState.test.ts | 22 + .../src/hooks/useSpeakerSuggestions.ts | 12 + app/renderer/src/lib/ipc.ts | 2 + app/renderer/src/routes/Settings.tsx | 5 + .../src/routes/settings/PeopleTab.tsx | 282 ++++++++++++ .../src/routes/settings/SettingsNav.tsx | 5 +- ...speaker-dominance-and-people-management.md | 273 ++++++++++++ ...2026-08-06-person-voice-sample-playback.md | 401 ++++++++++++++++++ ...-dominance-and-people-management-design.md | 55 +++ ...-06-person-voice-sample-playback-design.md | 80 ++++ e2e/specs/people-management.t2.spec.ts | 165 +++++++ e2e/specs/speaker-naming.t2.spec.ts | 20 + e2e/specs/speaker-review.t1.spec.ts | 220 +++++++++- simple_recorder.py | 180 +++++++- src/transcriber.py | 154 +++++-- tests/test_audio_preprocess.py | 3 +- tests/test_person_sample_audio_cli.py | 288 +++++++++++++ tests/test_suggest_speakers_cli.py | 13 + tests/test_transcriber_diarisation.py | 121 ++++++ 27 files changed, 2630 insertions(+), 205 deletions(-) create mode 100644 app/person-sample-ipc.js create mode 100644 app/person-sample-ipc.test.js create mode 100644 app/renderer/src/routes/settings/PeopleTab.tsx create mode 100644 docs/superpowers/plans/2026-08-05-speaker-dominance-and-people-management.md create mode 100644 docs/superpowers/plans/2026-08-06-person-voice-sample-playback.md create mode 100644 docs/superpowers/specs/2026-08-05-speaker-dominance-and-people-management-design.md create mode 100644 docs/superpowers/specs/2026-08-06-person-voice-sample-playback-design.md create mode 100644 e2e/specs/people-management.t2.spec.ts create mode 100644 tests/test_person_sample_audio_cli.py diff --git a/app/e2e-mock-ipc.js b/app/e2e-mock-ipc.js index 9d04bf50..c65ff23f 100644 --- a/app/e2e-mock-ipc.js +++ b/app/e2e-mock-ipc.js @@ -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 @@ -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 } @@ -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. @@ -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()] }; @@ -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 @@ -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 @@ -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 @@ -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; diff --git a/app/main.js b/app/main.js index 758ad0b7..158a432e 100644 --- a/app/main.js +++ b/app/main.js @@ -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'); @@ -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 }; @@ -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 diff --git a/app/package.json b/app/package.json index a3530de2..360bd39a 100644 --- a/app/package.json +++ b/app/package.json @@ -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", diff --git a/app/person-sample-ipc.js b/app/person-sample-ipc.js new file mode 100644 index 00000000..3c80cc32 --- /dev/null +++ b/app/person-sample-ipc.js @@ -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 }; diff --git a/app/person-sample-ipc.test.js b/app/person-sample-ipc.test.js new file mode 100644 index 00000000..1819e435 --- /dev/null +++ b/app/person-sample-ipc.test.js @@ -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', + }); + } +}); diff --git a/app/preload.js b/app/preload.js index 385206a5..2089ec6d 100644 --- a/app/preload.js +++ b/app/preload.js @@ -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), diff --git a/app/renderer/src/components/SpeakerReviewPanel.tsx b/app/renderer/src/components/SpeakerReviewPanel.tsx index 31bee6df..1eeb94b3 100644 --- a/app/renderer/src/components/SpeakerReviewPanel.tsx +++ b/app/renderer/src/components/SpeakerReviewPanel.tsx @@ -1,10 +1,9 @@ import * as React from 'react'; import { - Check, ChevronDown, ChevronRight, Loader2, Play, Square, Trash2, Undo2, Users, UserPlus, X, + Check, ChevronDown, ChevronRight, Loader2, Play, Square, Undo2, Users, UserPlus, X, } from 'lucide-react'; import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter, DialogClose } from '@/components/ui/dialog'; -import { ConfirmDialog } from '@/components/ui/confirm-dialog'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { @@ -12,12 +11,11 @@ import { usePersonProfiles, useConfirmSpeaker, useGetSpeakerSampleAudio, - useDeletePersonProfile, useMarkSpeakerCluster, useSetClusterReviewState, meetingStemFromSummaryFile, } from '@/hooks/useSpeakerSuggestions'; -import type { PersonProfile, SpeakerSuggestion, StaleAssignment } from '@/lib/ipc'; +import type { SpeakerSuggestion, StaleAssignment } from '@/lib/ipc'; interface SpeakerReviewPanelProps { summaryFile: string; @@ -85,6 +83,51 @@ export function orderProfilesForRow( + profiles: T[], + query: string, +): T[] { + const needle = foldForSearch(query.trim()); + if (!needle) return profiles; + return profiles.filter((p) => foldForSearch(p.display_name).includes(needle)); +} + +export function shouldShowSpeakerReview( + meetingStem: string | null, + isDiarised: boolean, + hasSuggestionData: boolean, + rowCount: number, +): meetingStem is string { + return Boolean( + meetingStem + && hasSuggestionData + && rowCount > (isDiarised ? 0 : 1), + ); +} + /** Did a human look at this row and choose to leave it unnamed? * * Read out of the query payload rather than component state, which is the @@ -270,14 +313,14 @@ function PlaySampleButton({ * generic actions. Lives inside MeetingDetail's content flow, gated on * `is_diarised` -- see the speaker_identification plan doc's Phase 4. * - * Rows with status "none" AND zero candidates (nothing actionable at all -- - * in practice this is almost always the device owner's own mic-channel - * cluster, which never matches a named PersonProfile) are hidden entirely. - * Rows flagged `is_likely_artifact` (the real-data-validated echo/crosstalk - * pattern -- see SUGGESTION_MIN_AVG_TURN_SECONDS) are hidden BY DEFAULT but - * still reachable via a "Show N filtered rows" toggle -- never silently - * dropped, since a human might legitimately want to review one (e.g. a - * real quiet third participant). + * Two kinds of row are hidden BY DEFAULT but stay reachable via the "Show N + * filtered rows" toggle -- never silently dropped, since a human might + * legitimately want to review either: rows with status "none" AND zero + * candidates (nothing actionable at all -- in practice almost always the + * device owner's own mic-channel cluster, and the shape a row takes on after + * the person it pointed at is deleted), and rows flagged `is_likely_artifact` + * (the real-data-validated echo/crosstalk pattern -- see + * SUGGESTION_MIN_AVG_TURN_SECONDS). */ /** `action` names what the person actually clicked. Without it every * failure on this row read "Couldn't confirm", including a failed @@ -287,27 +330,21 @@ type ConfirmFeedback = { message: string; action?: 'confirm' | 'mark' | 'unmark' export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPanelProps) { const meetingStem = meetingStemFromSummaryFile(summaryFile); - const suggestionsQuery = useSpeakerSuggestions(isDiarised ? meetingStem : null); + const suggestionsQuery = useSpeakerSuggestions(meetingStem); const profilesQuery = usePersonProfiles(); const confirmSpeaker = useConfirmSpeaker(); - const deleteProfile = useDeletePersonProfile(); const markCluster = useMarkSpeakerCluster(); const setReviewState = useSetClusterReviewState(); const [expanded, setExpanded] = React.useState>(new Set()); const [changeOpenFor, setChangeOpenFor] = React.useState(null); + // Search query of the open "Change" picker. One piece of state rather than + // one per row: only a single picker can be open at a time (changeOpenFor), + // and it is cleared on every open/close. + const [personQuery, setPersonQuery] = React.useState(''); const [newPersonRow, setNewPersonRow] = React.useState(null); const [newPersonName, setNewPersonName] = React.useState(''); const [showFiltered, setShowFiltered] = React.useState(false); - const [deleteTarget, setDeleteTarget] = React.useState(null); - // Rows with status "none" and zero candidates are normally hidden as - // "nothing actionable" (in practice almost always the device owner's own - // mic-channel cluster). But deleting a person clears suggested_person_id/ - // candidates for any cluster that pointed at them, which would otherwise - // make that row -- which the user was just looking at -- vanish with no - // way to give it a new name. Force those specific rows to stay visible - // for the rest of this session once that's happened. - const [keepVisible, setKeepVisible] = React.useState>(new Set()); // Error acknowledgment only -- a SUCCESSFUL confirm needs no separate // feedback state: useConfirmSpeaker's onSuccess awaits the suggestions // refetch before resolving, so by the time this fires the row's own @@ -317,26 +354,20 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan // confirm attempt starts on that row. const [feedback, setFeedback] = React.useState>(new Map()); - if (!isDiarised || !meetingStem) return null; - const rows: Row[] = []; const channels = suggestionsQuery.data?.channels ?? {}; for (const channel of Object.keys(channels)) { for (const [diarizationSpeakerId, suggestion] of Object.entries(channels[channel])) { - const key = rowKey({ channel, diarizationSpeakerId }); - // A MARKED cluster is deliberately status "none" with zero - // candidates, which is exactly the "nothing actionable" shape hidden - // below -- so without this it would disappear the moment it was - // marked, taking the only way to undo a misclick with it. Marking is - // a statement about the recording, not a dismissal. - const nothingActionable = - suggestion.status === 'none' - && suggestion.candidates.length === 0 - && !suggestion.contains_multiple_speakers; - if (nothingActionable && !keepVisible.has(key)) continue; rows.push({ channel, diarizationSpeakerId, suggestion }); } } + + if (!shouldShowSpeakerReview( + meetingStem, + isDiarised, + Boolean(suggestionsQuery.data), + rows.length, + )) return null; // Most speaking time first. Reviewing is voluntary and can be abandoned at // any point, so the order decides how much of the transcript the first // couple of decisions actually cover -- and the more the diarizer splits a @@ -371,18 +402,36 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan // behind a toggle the user has no reason to open. Same for a row kept // generic: the marking IS a deliberate action, and its undo lives on the // row itself. + // "Nothing actionable at all" -- no suggestion, nobody to pick from. In + // practice almost always the device owner's own mic-channel cluster, which + // never matches a named person. + // + // Demoted into the filtered list rather than dropped outright. Deleting a + // person puts a row into exactly this shape (the backend recomputes + // candidates from the profiles, so the cluster that pointed at them is left + // with none), and until now the only thing keeping such a row on screen was + // a session-scoped `keepVisible` set the panel's own delete button filled. + // With deletion moved to Settings there is no such moment to hook into, and + // dropping the row would leave the cluster the user just detached from a + // person unreachable -- no way to give it a new name, no way to even see it. + // A reachable row behind the existing toggle survives a remount, which the + // old set never did. + const nothingActionable = (row: Row) => + row.suggestion.status === 'none' && row.suggestion.candidates.length === 0; + // A row a human has explicitly marked or kept generic is never filtered: + // marking is a statement about the recording, not a dismissal, and its undo + // lives on the row itself -- burying that behind a toggle would hide the + // only way back from a misclick. const isFiltered = (row: Row) => - row.suggestion.is_likely_artifact + (row.suggestion.is_likely_artifact || nothingActionable(row)) && !row.suggestion.contains_multiple_speakers && !isKeptGeneric(row.suggestion); - const artifactRows = rows.filter(isFiltered); + const filteredRows = rows.filter(isFiltered); const primaryRows = rows.filter((row) => !isFiltered(row)); const visibleRows = showFiltered ? rows : primaryRows; const recordingAvailable = suggestionsQuery.data?.recording_available ?? false; const staleNotice = staleAssignmentNotice(suggestionsQuery.data?.stale_assignments); - if (!suggestionsQuery.data || rows.length === 0) return null; - const duplicateProfile = newPersonName.trim() ? (profilesQuery.data ?? []).find((p) => namesCollide(p.display_name, newPersonName)) : undefined; @@ -618,7 +667,13 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan )} setChangeOpenFor(open ? key : null)} + onOpenChange={(open) => { + setChangeOpenFor(open ? key : null); + // Each opening starts a fresh search. Carrying the last + // query over would silently hide most of the list on a + // picker the user just opened. + setPersonQuery(''); + }} > - + {(profilesQuery.data ?? []).length === 0 ? (
No known people yet
) : ( - orderProfilesForRow(profilesQuery.data ?? [], alreadyInMeeting).map((profile) => ( -
- - -
- )) +
+ {matches.length === 0 ? ( +
+ No match +
+ ) : ( + matches.map((profile) => ( + + )) + )} +
+ + ); + })() )}
@@ -855,7 +935,7 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan })} - {artifactRows.length > 0 && ( + {filteredRows.length > 0 && ( )} @@ -914,30 +994,6 @@ export function SpeakerReviewPanel({ summaryFile, isDiarised }: SpeakerReviewPan - !open && setDeleteTarget(null)} - title={deleteTarget ? `Delete ${deleteTarget.display_name}?` : ''} - description="This removes them from every meeting's speaker suggestions and deletes their voice profile. This can't be undone." - confirmLabel="Delete" - destructive - isPending={deleteProfile.isPending} - onConfirm={async () => { - if (!deleteTarget) return; - const affectedKeys = rows - .filter((r) => r.suggestion.suggested_person_id === deleteTarget.person_id) - .map(rowKey); - await deleteProfile.mutateAsync(deleteTarget.person_id); - if (affectedKeys.length > 0) { - setKeepVisible((prev) => { - const next = new Set(prev); - affectedKeys.forEach((k) => next.add(k)); - return next; - }); - } - setDeleteTarget(null); - }} - /> ); } diff --git a/app/renderer/src/components/speakerReviewOrdering.test.ts b/app/renderer/src/components/speakerReviewOrdering.test.ts index 0314be11..378d5ce4 100644 --- a/app/renderer/src/components/speakerReviewOrdering.test.ts +++ b/app/renderer/src/components/speakerReviewOrdering.test.ts @@ -1,6 +1,6 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; -import { orderProfilesForRow } from './SpeakerReviewPanel'; +import { orderProfilesForRow, filterProfilesByQuery } from './SpeakerReviewPanel'; const p = (display_name: string) => ({ display_name, person_id: `id-${display_name.toLowerCase()}` }); @@ -44,3 +44,69 @@ describe('orderProfilesForRow identity', () => { expect(ordered.map((x) => x.person_id)).toEqual(['id-a', 'id-b']); }); }); + +describe('filterProfilesByQuery', () => { + it('matches anywhere in the name, not just the start', () => { + const found = filterProfilesByQuery([p('Jens Lindemann'), p('Marie Kutza')], 'linde'); + expect(found.map((x) => x.display_name)).toEqual(['Jens Lindemann']); + }); + + it('ignores case', () => { + const found = filterProfilesByQuery([p('Ludger Dierkes')], 'DIERKES'); + expect(found.map((x) => x.display_name)).toEqual(['Ludger Dierkes']); + }); + + it('uses the same case folding regardless of the operating-system locale', () => { + const nativeLocaleLower = String.prototype.toLocaleLowerCase; + const localeSpy = vi.spyOn(String.prototype, 'toLocaleLowerCase').mockImplementation( + function forceTurkishLocale(this: string) { + return nativeLocaleLower.call(this, 'tr'); + }, + ); + try { + const found = filterProfilesByQuery([p('Irmak')], 'irmak'); + expect(found.map((x) => x.display_name)).toEqual(['Irmak']); + } finally { + localeSpy.mockRestore(); + } + }); + + it('finds an accented name typed without accents', () => { + // A user on a keyboard that cannot produce the character otherwise has + // no path to that person at all. + const found = filterProfilesByQuery([p('Müller'), p('Mahler')], 'muller'); + expect(found.map((x) => x.display_name)).toEqual(['Müller']); + }); + + it('finds an unaccented name typed with accents', () => { + const found = filterProfilesByQuery([p('Muller')], 'müller'); + expect(found.map((x) => x.display_name)).toEqual(['Muller']); + }); + + it('returns everything for an empty or whitespace-only query', () => { + const all = [p('Zoe'), p('Alice')]; + expect(filterProfilesByQuery(all, '')).toHaveLength(2); + expect(filterProfilesByQuery(all, ' ')).toHaveLength(2); + }); + + it('returns nothing when no name matches', () => { + expect(filterProfilesByQuery([p('Zoe'), p('Alice')], 'qqq')).toEqual([]); + }); + + it('preserves the order it was given, so "here" people stay on top', () => { + // Composed with orderProfilesForRow in the picker: filtering must not + // re-sort, or the already-in-this-meeting people lose their position. + const ordered = orderProfilesForRow( + [p('Anna Bauer'), p('Bea Bauer'), p('Zoe Bauer')], + new Set(['id-zoe bauer']), + ); + const found = filterProfilesByQuery(ordered, 'bauer'); + expect(found.map((x) => x.display_name)).toEqual(['Zoe Bauer', 'Anna Bauer', 'Bea Bauer']); + }); + + it('does not mutate the list it was given', () => { + const input = [p('Zoe'), p('Alice')]; + filterProfilesByQuery(input, 'zoe'); + expect(input.map((x) => x.display_name)).toEqual(['Zoe', 'Alice']); + }); +}); diff --git a/app/renderer/src/components/speakerReviewState.test.ts b/app/renderer/src/components/speakerReviewState.test.ts index 942f447e..49568356 100644 --- a/app/renderer/src/components/speakerReviewState.test.ts +++ b/app/renderer/src/components/speakerReviewState.test.ts @@ -4,6 +4,7 @@ import { isKeptGeneric, showsKeepGenericButton, showsNamingActions, + shouldShowSpeakerReview, staleAssignmentNotice, } from './SpeakerReviewPanel'; @@ -26,6 +27,27 @@ const suggestion = (over: Record = {}) => ...over, }) as never; +describe('shouldShowSpeakerReview', () => { + it('opens a non-diarised transcript only when its sidecar has multiple clusters', () => { + expect(shouldShowSpeakerReview('meeting', false, true, 2)).toBe(true); + expect(shouldShowSpeakerReview('meeting', false, true, 1)).toBe(false); + expect(shouldShowSpeakerReview('meeting', false, true, 0)).toBe(false); + }); + + it('preserves a one-cluster panel for a diarised transcript', () => { + expect(shouldShowSpeakerReview('meeting', true, true, 1)).toBe(true); + }); + + it('hides a diarised transcript when no speaker rows exist', () => { + expect(shouldShowSpeakerReview('meeting', true, true, 0)).toBe(false); + }); + + it('waits for a meeting stem and a completed suggestion response', () => { + expect(shouldShowSpeakerReview(null, true, true, 2)).toBe(false); + expect(shouldShowSpeakerReview('meeting', true, false, 2)).toBe(false); + }); +}); + describe('isKeptGeneric', () => { it('reads the marking out of the query payload, not component state', () => { // The whole point of persisting it: this derivation has no memory of diff --git a/app/renderer/src/hooks/useSpeakerSuggestions.ts b/app/renderer/src/hooks/useSpeakerSuggestions.ts index 02a09c45..36420b0f 100644 --- a/app/renderer/src/hooks/useSpeakerSuggestions.ts +++ b/app/renderer/src/hooks/useSpeakerSuggestions.ts @@ -141,6 +141,18 @@ export function useGetSpeakerSampleAudio() { }); } +/** Fetch one representative clip for a known person on demand. + * + * The backend resolves private meeting/cluster provenance at click time and + * returns only audio bytes, so the renderer never caches local source details. + */ +export function useGetPersonSampleAudio() { + return useMutation({ + mutationFn: async (personId: string) => + unwrap(await ipc().speakers.getPersonSampleAudio(personId)), + }); +} + /** Marking a cluster as holding more than one person. Invalidates the whole * speakers tree rather than just this meeting's suggestions: the marking * withdraws the cluster from meeting-wide person exclusivity, so ANOTHER diff --git a/app/renderer/src/lib/ipc.ts b/app/renderer/src/lib/ipc.ts index 08d96a78..cb39f819 100644 --- a/app/renderer/src/lib/ipc.ts +++ b/app/renderer/src/lib/ipc.ts @@ -437,6 +437,7 @@ export interface PersonProfile { display_name: string; prototype_counts: Record; hard_negative_counts: Record; + sample_available: boolean; updated_at: number; } export type ListPersonProfilesResponse = Result<{ person_profiles: PersonProfile[] }>; @@ -1154,6 +1155,7 @@ export interface StenoaiBridge { [meetingStem: string, channel: string, diarizationSpeakerId: string, segmentIndex?: number], GetSpeakerSampleAudioResponse >; + getPersonSampleAudio: RequestFn<[id: string], GetSpeakerSampleAudioResponse>; markCluster: RequestFn<[params: MarkSpeakerClusterParams], MarkSpeakerClusterResponse>; setClusterReviewState: RequestFn< [params: SetClusterReviewStateParams], diff --git a/app/renderer/src/routes/Settings.tsx b/app/renderer/src/routes/Settings.tsx index 049f5c9b..3c1282e1 100644 --- a/app/renderer/src/routes/Settings.tsx +++ b/app/renderer/src/routes/Settings.tsx @@ -10,6 +10,7 @@ import { SettingsNav, SETTINGS_TAB_LABELS, type SettingsTabId } from './settings import { GeneralTab } from './settings/GeneralTab'; import { AiTab } from './settings/AiTab'; import { TemplatesTab } from './settings/TemplatesTab'; +import { PeopleTab } from './settings/PeopleTab'; import { OrganisationTab } from './settings/OrganisationTab'; import { AdvancedTab } from './settings/AdvancedTab'; import { IntegrationsTab } from './settings/IntegrationsTab'; @@ -39,6 +40,8 @@ const SETTINGS_TAB_DESCRIPTIONS: Partial> ), + people: + 'Everyone Steno has learned to recognise by voice. Deleting someone here removes their voice profile from every meeting.', organisation: 'Connect to Steno Enterprise for your organisation.', }; @@ -51,6 +54,7 @@ const DEEP_LINK_IDS = [ 'transcription', 'ai', 'templates', + 'people', 'organisation', 'integrations', 'advanced', @@ -228,6 +232,7 @@ export function Settings() { {tab === 'general' && } {tab === 'ai' && } {tab === 'templates' && } + {tab === 'people' && } {tab === 'organisation' && } {tab === 'integrations' && } {tab === 'advanced' && } diff --git a/app/renderer/src/routes/settings/PeopleTab.tsx b/app/renderer/src/routes/settings/PeopleTab.tsx new file mode 100644 index 00000000..6ef7ec8c --- /dev/null +++ b/app/renderer/src/routes/settings/PeopleTab.tsx @@ -0,0 +1,282 @@ +import * as React from 'react'; +import { Loader2, Play, Square, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ConfirmDialog } from '@/components/ui/confirm-dialog'; +import { + useDeletePersonProfile, + useGetPersonSampleAudio, + usePersonProfiles, +} from '@/hooks/useSpeakerSuggestions'; +import type { PersonProfile } from '@/lib/ipc'; +import { SettingRow, COMPACT_BTN } from './primitives'; + +// --------------------------------------------------------------------------- +// People - the one place a voice profile can be deleted. +// +// Deleting used to live inline in the speaker-review panel's "Change" picker, +// one icon away from the button that merely assigns a person to a cluster. +// That put a global, irreversible action (it removes the voice profile from +// EVERY meeting, and strips the derived hard-negative evidence out of everyone +// else's profile) inside a control whose entire job is a local, reversible +// choice about one cluster. Managing who Steno knows is a settings-level +// concern, so it lives at settings level. +// --------------------------------------------------------------------------- + +/** Total stored voice samples across recording contexts. + * + * `prototype_counts` is keyed by recording context (in-person/remote), not by + * meeting -- the profile DTO carries counts, not the prototypes themselves, + * so "how many meetings is this person in" is not answerable here without + * widening the backend payload. The sample count is the honest thing this + * screen can say: it is what recognition actually runs on. */ +function sampleCount(profile: PersonProfile): number { + return Object.values(profile.prototype_counts ?? {}).reduce( + (sum, n) => sum + (Number.isFinite(n) ? n : 0), + 0, + ); +} + +function describeSamples(profile: PersonProfile): string { + const n = sampleCount(profile); + // A profile with no samples is a real state, not an error: "New person" + // creates the profile before any voice has been attached to it, and every + // enrollment path can leave one behind. Saying "0 voice samples" would + // read as damage; naming it as not-yet-learned is what it is. + if (n === 0) return 'No voice samples yet - Steno cannot recognise them automatically.'; + return `${n} voice sample${n === 1 ? '' : 's'}`; +} + +function base64ToBlobUrl(base64: string): string { + const bytes = Uint8Array.from(atob(base64), (character) => character.charCodeAt(0)); + return URL.createObjectURL(new Blob([bytes], { type: 'audio/wav' })); +} + +export function PeopleTab() { + const profilesQuery = usePersonProfiles(); + const deleteProfile = useDeletePersonProfile(); + const getPersonSample = useGetPersonSampleAudio(); + const [deleteTarget, setDeleteTarget] = React.useState(null); + const [deleteError, setDeleteError] = React.useState(false); + const [pendingPersonId, setPendingPersonId] = React.useState(null); + const [playingPersonId, setPlayingPersonId] = React.useState(null); + const [playErrorPersonId, setPlayErrorPersonId] = React.useState(null); + const audioRef = React.useRef(null); + const objectUrlRef = React.useRef(null); + const playbackGenerationRef = React.useRef(0); + + const releaseMedia = React.useCallback(() => { + if (audioRef.current) { + audioRef.current.onended = null; + audioRef.current.onerror = null; + audioRef.current.pause(); + audioRef.current = null; + } + if (objectUrlRef.current) { + URL.revokeObjectURL(objectUrlRef.current); + objectUrlRef.current = null; + } + }, []); + + const stopPlayback = React.useCallback(() => { + playbackGenerationRef.current += 1; + releaseMedia(); + setPendingPersonId(null); + setPlayingPersonId(null); + }, [releaseMedia]); + + React.useEffect(() => () => { + playbackGenerationRef.current += 1; + releaseMedia(); + }, [releaseMedia]); + + const togglePlayback = async (profile: PersonProfile) => { + if (playingPersonId === profile.person_id) { + stopPlayback(); + return; + } + + stopPlayback(); + setPlayErrorPersonId(null); + setPendingPersonId(profile.person_id); + const generation = playbackGenerationRef.current; + + try { + const result = await getPersonSample.mutateAsync(profile.person_id); + if (generation !== playbackGenerationRef.current) return; + + const objectUrl = base64ToBlobUrl(result.audio_base64); + const audio = new Audio(objectUrl); + objectUrlRef.current = objectUrl; + audioRef.current = audio; + + audio.onended = () => { + if (generation !== playbackGenerationRef.current) return; + releaseMedia(); + setPlayingPersonId(null); + }; + audio.onerror = () => { + if (generation !== playbackGenerationRef.current) return; + releaseMedia(); + setPlayingPersonId(null); + setPlayErrorPersonId(profile.person_id); + }; + + await audio.play(); + if (generation !== playbackGenerationRef.current) { + releaseMedia(); + return; + } + setPlayingPersonId(profile.person_id); + } catch { + if (generation === playbackGenerationRef.current) { + releaseMedia(); + setPlayingPersonId(null); + setPlayErrorPersonId(profile.person_id); + } + } finally { + if (generation === playbackGenerationRef.current) { + setPendingPersonId(null); + } + } + }; + + const profiles = React.useMemo( + () => + [...(profilesQuery.data ?? [])].sort((a, b) => + a.display_name.localeCompare(b.display_name), + ), + [profilesQuery.data], + ); + + if (profilesQuery.isLoading) { + return ( +
+ + Loading people… +
+ ); + } + + if (profilesQuery.isError) { + return ( +
+ Could not load people. +
+ ); + } + + return ( +
+ {profiles.length === 0 ? ( +
+ No people yet. Name a speaker in a meeting and they will appear here. +
+ ) : ( + profiles.map((profile, i) => ( + + {describeSamples(profile)} + {playErrorPersonId === profile.person_id && ( + + Could not play this voice sample. Try again. + + )} + + )} + noBorder={i === profiles.length - 1} + > +
+ {profile.sample_available && ( + + )} + +
+
+ )) + )} + + { + if (!open) { + setDeleteTarget(null); + setDeleteError(false); + } + }} + title={deleteTarget ? `Delete ${deleteTarget.display_name}?` : ''} + // Wording carried over verbatim from the picker this replaced: the + // reach of the action is the whole point of stating it, and it does + // not get smaller for being triggered from settings. + description={( + + This removes them from every meeting's speaker suggestions and deletes their + voice profile. This can't be undone. + {deleteError && ( + + Could not delete this person. Try again. + + )} + + )} + confirmLabel="Delete" + destructive + isPending={deleteProfile.isPending} + onConfirm={async () => { + if (!deleteTarget) return; + setDeleteError(false); + try { + await deleteProfile.mutateAsync(deleteTarget.person_id); + setDeleteTarget(null); + } catch { + setDeleteError(true); + } + }} + /> +
+ ); +} diff --git a/app/renderer/src/routes/settings/SettingsNav.tsx b/app/renderer/src/routes/settings/SettingsNav.tsx index 5b3dc018..db03579b 100644 --- a/app/renderer/src/routes/settings/SettingsNav.tsx +++ b/app/renderer/src/routes/settings/SettingsNav.tsx @@ -7,6 +7,7 @@ import { Plug, Settings2, Sparkles, + Users, Wrench, type LucideIcon, } from 'lucide-react'; @@ -15,11 +16,12 @@ import { cn } from '@/lib/utils'; // The full set of nav rail destinations. Distinct from Settings.tsx's // deep-linkable TabId, which additionally accepts the legacy 'transcription' // id as an alias that resolves onto 'ai' — the nav rail itself only ever -// renders/highlights these seven. +// renders/highlights the ids listed here. export type SettingsTabId = | 'general' | 'ai' | 'templates' + | 'people' | 'organisation' | 'integrations' | 'advanced' @@ -43,6 +45,7 @@ const NAV_GROUPS: NavGroup[] = [ { id: 'general', label: 'Preferences', icon: Settings2 }, { id: 'ai', label: 'AI', icon: Sparkles }, { id: 'templates', label: 'Templates', icon: LayoutTemplate }, + { id: 'people', label: 'People', icon: Users }, ], }, { diff --git a/docs/superpowers/plans/2026-08-05-speaker-dominance-and-people-management.md b/docs/superpowers/plans/2026-08-05-speaker-dominance-and-people-management.md new file mode 100644 index 00000000..f155d3b3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-05-speaker-dominance-and-people-management.md @@ -0,0 +1,273 @@ +# Speaker Dominance and People Management Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. +> Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Preserve sustained minority speakers, make their sidecar-backed review panel reachable, and complete end-to-end coverage for People management. + +**Architecture:** Extend the existing dominance decision with an absolute per-cluster floor and carry its eligible cluster IDs into self-voiceprint matching. +Let the renderer derive review-panel availability from the suggestion payload when the transcript flag is false. +Exercise profile management through the existing IPC bridge and Settings UI without changing the backend contract. + +**Tech Stack:** Python `unittest`, React and TypeScript, TanStack Query, Electron mock IPC, Playwright T1 and T2. + +## Global Constraints + +- Keep `CHANNEL_DOMINANCE_THRESHOLD` at `0.92`. +- Use 15 seconds as the sustained minority floor. +- Reuse `SUGGESTION_MIN_AVG_TURN_SECONDS` as the fragmented-artifact floor. +- Keep short folded clusters in the sidecar for review and provenance, but do not give them a separate transcript label. +- Preserve macOS and Windows shared-code behavior. +- Set `STENOAI_USER_DATA_DIR` through the existing Playwright fixture for every T2 test. +- Do not modify `CHANGELOG.md` or generated files. +- Do not push or open a pull request. + +--- + +### Task 1: Classify sustained minority clusters + +**Files:** + +- Modify: `src/transcriber.py` +- Test: `tests/test_transcriber_diarisation.py` + +**Interfaces:** + +- Produces: `_cluster_channel_label_plan(diar_segments, legacy_label) -> tuple[Optional[dict[str, str]], set[str]]` +- Preserves: `_cluster_channel_labels(diar_segments, legacy_label) -> Optional[dict[str, str]]` +- Extends: `_apply_voiceprint_matches(..., eligible_speaker_ids: Optional[set[str]] = None)` + +- [ ] **Step 1: Add failing unit tests** + +Add literal segment fixtures proving these behaviors: + +```python +# 3487.4 / 111.1 / 61.2 / 2.6 seconds. +# The two sustained minority clusters get placeholders. +# The 2.6-second blip inherits the dominant legacy label. + +# A normal 1:1 shape with an 11.84-second minority remains collapsed. + +# A fragmented 18-second minority whose average turn is 0.6 seconds remains collapsed. + +# A self match on a sustained minority re-anchors "You" while the folded +# blip inherits the previous dominant cluster's placeholder. +``` + +- [ ] **Step 2: Run the focused tests and verify RED** + +Run: + +```bash +venv/bin/python -m unittest \ + tests.test_transcriber_diarisation.ClusterChannelLabelsTests \ + tests.test_transcriber_diarisation.ApplyVoiceprintMatchesTests +``` + +Expected: the sustained-minority and eligible-cluster tests fail because the current ratio-only rule returns `None` and voiceprint matching considers every embedding. + +- [ ] **Step 3: Implement the label plan** + +Add `CHANNEL_DOMINANCE_MIN_MINOR_SPEECH_SECONDS = 15.0`. +Compute total speech per cluster once. +Below the ratio threshold, keep every cluster eligible and preserve current labels. +At or above the ratio threshold, return `None` when no minority cluster reaches both the absolute duration floor and the average-turn floor. +Otherwise label the dominant cluster with the legacy label, give each sustained minority its own placeholder, fold other minorities onto the dominant label, and return only dominant plus sustained minorities as eligible. + +Pass eligible IDs into `_apply_voiceprint_matches`. +Limit its distance search to eligible embeddings. +When the self match moves the legacy label, keep folded blips attached to the old dominant label. + +- [ ] **Step 4: Run focused and neighboring tests and verify GREEN** + +Run the focused command from Step 2 and: + +```bash +venv/bin/python -m unittest tests.test_transcriber_diarisation +``` + +- [ ] **Step 5: Commit Task 1 explicitly** + +```bash +git add src/transcriber.py tests/test_transcriber_diarisation.py +git commit -m "fix(speakers): keep sustained minority speakers distinct" +``` + +### Task 2: Open the panel from sidecar evidence + +**Files:** + +- Modify: `app/renderer/src/components/SpeakerReviewPanel.tsx` +- Modify: `app/e2e-mock-ipc.js` +- Modify: `app/main.js` +- Test: `e2e/specs/speaker-review.t1.spec.ts` +- Test: `e2e/specs/speaker-naming.t2.spec.ts` + +**Interfaces:** + +- Consumes: `suggest-speakers` response `channels` +- Preserves: the `isDiarised` prop as the transcript-label signal + +- [ ] **Step 1: Add a failing T1 test** + +Add a mock mode that returns the existing multi-cluster speaker sidecar payload with `is_diarised: false`. +Assert that navigating to the meeting renders `speaker-review-panel` and at least two speaker rows. + +- [ ] **Step 2: Build the renderer and verify RED** + +```bash +cd app +npm run build:renderer +npm run test:e2e -- --project=t1 --grep "sidecar has multiple clusters" +``` + +Expected: the panel never appears because the component currently returns early on `!isDiarised`. + +- [ ] **Step 3: Implement payload-based availability** + +Enable `useSpeakerSuggestions` whenever `meetingStem` exists. +Treat a missing speaker sidecar as an expected empty CLI result instead of a backend failure, so the query does not retry a failing process. +Return `null` when there is no stem, no suggestion payload, no flattened sidecar rows, or when `isDiarised` is false and the flattened sidecar row count is one. + +- [ ] **Step 4: Rebuild and verify GREEN** + +Repeat Step 2 and run the complete `speaker-review.t1.spec.ts` file. + +- [ ] **Step 5: Commit Task 2 explicitly** + +```bash +git add app/renderer/src/components/SpeakerReviewPanel.tsx app/e2e-mock-ipc.js e2e/specs/speaker-review.t1.spec.ts +git commit -m "fix(speakers): open review from sidecar clusters" +``` + +### Task 3: Cover the large People picker and Settings deletion + +**Files:** + +- Modify: `app/e2e-mock-ipc.js` +- Test: `e2e/specs/speaker-review.t1.spec.ts` + +**Interfaces:** + +- Consumes: `PERSON_SEARCH_THRESHOLD = 8` +- Preserves: `delete-person-profile` mock recomputation of suggestions + +- [ ] **Step 1: Add a many-profile mock mode and picker assertions** + +Seed at least ten complete profile DTOs only when the new environment flag is set. +Open Change, assert the search field appears, filter to one profile, verify `No match` for a missing query, and assert there is no `speaker-delete-person-*` control. + +- [ ] **Step 2: Rewrite the existing deletion flow through Settings** + +Confirm Person Alpha in the meeting, navigate to `/settings?tab=people`, delete Person Alpha after checking the global warning, navigate back to the meeting, reveal filtered rows, and assert the former row is unidentified and no longer contains Person Alpha. + +- [ ] **Step 3: Run the two focused T1 tests** + +```bash +cd app +npm run build:renderer +npm run test:e2e -- --project=t1 --grep "searches a large people library|People settings deletion" +``` + +Expected: both pass against the existing renderer implementation. +Mutation-check the search test by temporarily changing the threshold or filter and confirm it fails before restoring production code. + +- [ ] **Step 4: Run the complete speaker-review T1 file** + +```bash +cd app +npm run test:e2e -- --project=t1 e2e/specs/speaker-review.t1.spec.ts +``` + +- [ ] **Step 5: Commit Task 3 explicitly** + +```bash +git add app/e2e-mock-ipc.js e2e/specs/speaker-review.t1.spec.ts +git commit -m "test(speakers): cover People picker and deletion" +``` + +### Task 4: Prove People deletion through the real backend + +**Files:** + +- Create: `e2e/specs/people-management.t2.spec.ts` + +**Interfaces:** + +- Consumes: `window.stenoai.speakers.createProfile`, `listProfiles`, and `deleteProfile` +- Verifies: `/config.json` `person_profiles` + +- [ ] **Step 1: Write the T2 test** + +Create two profiles through the real preload bridge. +Navigate to `/settings?tab=people`. +Assert alphabetical names, the zero-sample explanation, and the People header's global scope. +Open one delete dialog and assert the warning says the profile is removed from every meeting and cannot be restored. +Confirm deletion and poll `config.json` until only the untouched profile remains. +Verify the real user-data directory signature is unchanged. + +- [ ] **Step 2: Run the test and verify RED if wiring is incomplete** + +Build `dist/stenoai` first because T2 launches the bundled backend. + +```bash +venv/bin/pyinstaller stenoai.spec --noconfirm +cd app +npm run build:renderer +npm run test:e2e -- --project=t2 ../e2e/specs/people-management.t2.spec.ts +``` + +- [ ] **Step 3: Make only the test or existing UI corrections required by the real contract** + +Do not add a new backend API. +Keep fixture helpers unchanged unless the real bridge cannot create a profile before navigation. + +- [ ] **Step 4: Run the focused T2 and existing speaker compatibility specs** + +```bash +cd app +npm run test:e2e -- --project=t2 ../e2e/specs/people-management.t2.spec.ts ../e2e/specs/speaker-naming.t2.spec.ts ../e2e/specs/speaker-multi-marking.t2.spec.ts +``` + +- [ ] **Step 5: Commit Task 4 explicitly** + +```bash +git add e2e/specs/people-management.t2.spec.ts +git commit -m "test(speakers): cover People settings end to end" +``` + +### Task 5: Verify and review the branch + +**Files:** + +- Review: all files changed from `1891d8a8` + +- [ ] **Step 1: Run code quality checks** + +```bash +venv/bin/python -m unittest discover tests +venv/bin/ruff check . +cd app +npm run typecheck:renderer +npm run lint:renderer +npm run test:unit +``` + +- [ ] **Step 2: Run relevant E2E suites** + +Run the full T1 suite and the focused model-free T2 speaker specs. + +- [ ] **Step 3: Review the full branch diff** + +Inspect `git diff 1891d8a8...HEAD` for correctness, privacy, accessibility, platform parity, and accidental unrelated changes. + +- [ ] **Step 4: Obtain a cross-family second opinion** + +Ask an Opus or Fable reviewer to analyze the branch diff only. +Do not authorize edits or external actions. +Address verified findings with their own failing tests. + +- [ ] **Step 5: Remove this task's handoff only after completion** + +Delete `HANDOFF-feat-speaker-people-management.md` from the main checkout only if it still belongs to this completed task and no other session is using it. +Do not commit any handoff file. diff --git a/docs/superpowers/plans/2026-08-06-person-voice-sample-playback.md b/docs/superpowers/plans/2026-08-06-person-voice-sample-playback.md new file mode 100644 index 00000000..dd2637d3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-person-voice-sample-playback.md @@ -0,0 +1,401 @@ +# Person Voice Sample Playback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add one local, on-demand Play/Stop control to each People row whose confirmed profile still has an extractable source-audio sample. + +**Architecture:** Python remains the authority for sample eligibility and selection because it owns person profiles, sidecars, recording lookup, and extraction. Electron exposes one narrow read-only IPC call, while the People tab owns a single playback session so starting another person always stops and cleans up the current clip. + +**Tech Stack:** Python Click and `unittest`, Electron IPC/preload, React 19, TypeScript, TanStack Query, Web Audio via `HTMLAudioElement`, Vitest, Playwright T1/T2, PyInstaller. + +## Global Constraints + +- Show at most one representative sample per person and no sample-management UI. +- Never expose meeting ids, local paths, cluster ids, embeddings, or raw backend errors to the renderer. +- Consider only positive prototypes, never hard negatives. +- A sample is playable only while its recording, current sidecar run, channel, cluster, and positive-duration segment still exist. +- Rank candidates by `quality_score`, then `created_at`, then stable provenance fields. +- Keep only one active People-tab audio element and revoke every temporary object URL on stop, end, switch, error, and unmount. +- Use visible `Play` and `Stop` labels with person-specific accessible names. +- Do not add dependencies or modify `CHANGELOG.md`. +- Keep macOS and Windows behavior on the existing cross-platform recording and ffmpeg helpers. +- Every T2 fixture must set `STENOAI_USER_DATA_DIR` and must not inspect or change real user data. +- Use plain hyphens, never em dashes, in new text. + +--- + +## File map + +- `simple_recorder.py`: Resolve playable positive prototypes, report availability, and extract a selected person's sample. +- `tests/test_person_sample_audio_cli.py`: Pin eligibility, ranking, privacy-safe output, failure behavior, and WAV extraction. +- `app/main.js`: Register the new CLI-backed IPC handler. +- `app/preload.js`: Expose the person-sample call through the existing `speakers` namespace. +- `app/renderer/src/lib/ipc.ts`: Add `sample_available` and the typed person-sample request. +- `app/renderer/src/hooks/useSpeakerSuggestions.ts`: Add the fetch-on-click mutation. +- `app/renderer/src/routes/settings/PeopleTab.tsx`: Render and coordinate Play/Stop, cleanup, and inline failure. +- `app/e2e-mock-ipc.js`: Provide deterministic playable/unplayable profiles and a real-duration silent WAV for T1. +- `e2e/specs/speaker-review.t1.spec.ts`: Cover People playback behavior and sanitized failure copy. +- `e2e/specs/people-management.t2.spec.ts`: Cover the complete isolated real-backend extraction path. + +--- + +### Task 1: Resolve and extract a person's representative sample + +**Files:** +- Create: `tests/test_person_sample_audio_cli.py` +- Modify: `simple_recorder.py` + +**Interfaces:** +- Produces: `_resolve_person_sample(profile: dict, dirs: dict) -> Optional[dict]`. +- The returned private dict contains `meeting_id`, `channel`, `diarization_speaker_id`, `recording_path`, `pooled_segments`, `quality_score`, and `created_at` only inside Python. +- Produces CLI: `get-person-sample-audio PERSON_ID -> {"success": true, "audio_base64": str}` or a fixed JSON failure. +- Extends CLI: `list-person-profiles` returns `sample_available: bool` for each DTO. + +- [ ] **Step 1: Write failing resolver tests** + +Create `tests/test_person_sample_audio_cli.py` with temporary `output/`, `recordings/`, and `config.json` directories. +Seed profiles through `Config.add_speaker_prototype`, sidecars through `write_speakers_sidecar`, and small WAV fixtures through Python's `wave` module. +Assert all of these behaviors: + +```python +class PersonSampleResolutionTests(unittest.TestCase): + def test_selects_highest_quality_current_positive_prototype(self): ... + def test_uses_recency_then_stable_provenance_for_ties(self): ... + def test_rejects_stale_diarization_run(self): ... + def test_rejects_missing_recording_sidecar_channel_cluster_and_segments(self): ... + def test_ignores_hard_negatives(self): ... +``` + +The test that names the production change is `test_selects_highest_quality_current_positive_prototype`: it fails until `_resolve_person_sample` exists and returns the higher-quality prototype's private provenance. + +- [ ] **Step 2: Run the resolver tests and verify RED** + +Run: + +```bash +venv/bin/python -m unittest tests.test_person_sample_audio_cli.PersonSampleResolutionTests +``` + +Expected: import or assertion failure because `_resolve_person_sample` is absent. + +- [ ] **Step 3: Implement minimal deterministic resolution** + +In `simple_recorder.py`, add a private resolver used by both profile listing and extraction. +For every positive prototype: + +```python +sidecar = read_speakers_sidecar(dirs["output"], meeting_id) +run_id = (sidecar.get("diarization_run") or {}).get("run_id") if sidecar else None +if not sidecar or not prototype_run_matches(prototype, run_id): + continue +recording_path = _find_recording_file(dirs["recordings"], meeting_id) +channel_data = (sidecar.get("channels") or {}).get(channel) +raw_clusters = clusters_from_sidecar_channel(meeting_id, channel_data or {}) +``` + +Resolve merged fragments with `merge_same_channel_fragments`, pool segments from the resolved primary and `context.merged_from`, and require at least one segment whose `end` is greater than `start`. +Sort eligible candidates descending by numeric `quality_score`, descending by numeric `created_at`, then ascending by `(meeting_id, channel, diarization_speaker_id)`. +Return the first candidate or `None`. + +- [ ] **Step 4: Run resolver tests and verify GREEN** + +Run the command from Step 2. +Expected: all resolver tests pass. + +- [ ] **Step 5: Write failing CLI contract tests** + +Add: + +```python +class PersonSampleCliTests(unittest.TestCase): + def test_list_profiles_reports_only_boolean_availability(self): ... + def test_get_person_sample_audio_returns_valid_wav_base64(self): ... + def test_missing_person_returns_fixed_failure_without_provenance(self): ... + def test_unplayable_person_returns_fixed_failure_without_provenance(self): ... +``` + +Assert the list DTO contains `sample_available` but does not contain `meeting_id`, `channel`, `diarization_speaker_id`, `recording_path`, `prototypes`, or `embedding`. +Decode successful audio and assert it begins with `RIFF` and contains `WAVE`. +Assert failures contain only `success: false` and `error: "voice sample unavailable"`. + +- [ ] **Step 6: Run CLI tests and verify RED** + +Run: + +```bash +venv/bin/python -m unittest tests.test_person_sample_audio_cli.PersonSampleCliTests +``` + +Expected: failure because the list field and command do not exist. + +- [ ] **Step 7: Implement list availability and person extraction** + +Load `get_data_dirs()` once in `list_person_profiles`, call `_resolve_person_sample` per profile, and emit only `sample_available: resolved is not None` in addition to existing fields. + +Add the Click command: + +```python +@cli.command(name="get-person-sample-audio") +@click.argument("person_id") +def get_person_sample_audio(person_id): + profile = get_config().get_person_profile(person_id) + sample = _resolve_person_sample(profile, get_data_dirs()) if profile else None + if sample is None: + print(json.dumps({"success": False, "error": "voice sample unavailable"})) + return + # Extract into tempfile.gettempdir(), base64 the bytes, and unlink in finally. +``` + +Use `extract_speaker_sample_audio(recording_path, channel, pooled_segments, output_path)` with no segment index so the existing longest-clean-turn behavior remains authoritative. +Return the same fixed failure for extraction errors and always remove the temporary file in `finally`. + +- [ ] **Step 8: Run affected Python tests and quality checks** + +Run: + +```bash +venv/bin/python -m unittest tests.test_person_sample_audio_cli tests.test_suggest_speakers_cli tests.test_person_profile_cli +venv/bin/ruff check --select E9,F63,F7,F82 simple_recorder.py tests/test_person_sample_audio_cli.py +``` + +Expected: all tests and runtime-error rules pass. + +- [ ] **Step 9: Commit the backend slice** + +```bash +git add simple_recorder.py tests/test_person_sample_audio_cli.py +git commit -m "feat(speakers): serve representative person samples" +``` + +--- + +### Task 2: Wire playback into the People settings tab + +**Files:** +- Modify: `app/main.js` +- Modify: `app/preload.js` +- Modify: `app/renderer/src/lib/ipc.ts` +- Modify: `app/renderer/src/hooks/useSpeakerSuggestions.ts` +- Modify: `app/renderer/src/routes/settings/PeopleTab.tsx` +- Modify: `app/e2e-mock-ipc.js` +- Modify: `e2e/specs/speaker-review.t1.spec.ts` + +**Interfaces:** +- Consumes: `get-person-sample-audio PERSON_ID` and `PersonProfile.sample_available` from Task 1. +- Produces preload: `ipc().speakers.getPersonSampleAudio(personId: string)`. +- Produces hook: `useGetPersonSampleAudio()` returning a TanStack mutation whose data is `{ audio_base64: string }`. +- Produces UI test ids: `people-play-${person_id}` and `people-play-error-${person_id}`. + +- [ ] **Step 1: Write a failing T1 playback test** + +Extend the seeded People mock so Person Alpha has `sample_available: true`, Person Beta has stored prototype counts but `sample_available: false`, and empty profiles remain false. +Add a test that opens People and asserts: + +```typescript +await expect(page.getByRole('button', { name: 'Play voice sample for Person Alpha' })).toBeVisible(); +await expect(page.getByRole('button', { name: 'Play voice sample for Person Beta' })).toHaveCount(0); +await page.getByTestId('people-play-p-alpha').click(); +await expect(page.getByRole('button', { name: 'Stop voice sample for Person Alpha' })).toBeVisible(); +await page.getByTestId('people-play-p-alpha').click(); +await expect(page.getByRole('button', { name: 'Play voice sample for Person Alpha' })).toBeVisible(); +``` + +- [ ] **Step 2: Run the T1 test and verify RED** + +Run: + +```bash +cd app +npm run build:renderer +npm run test:e2e -- --project=t1 --grep "People settings plays one representative voice sample" +``` + +Expected: failure because no People playback control exists. + +- [ ] **Step 3: Add the typed IPC path** + +In `app/main.js`, register `get-person-sample-audio`, invoke the CLI with only `personId`, parse JSON, and use `parsePythonFailureJson` in the catch path. +In `app/preload.js`, expose `speakers.getPersonSampleAudio(id)`. +In `ipc.ts`, add `sample_available: boolean` to `PersonProfile` and type the method with `GetSpeakerSampleAudioResponse`. +In `useSpeakerSuggestions.ts`, add a fetch-on-click mutation analogous to `useGetSpeakerSampleAudio`: + +```typescript +export function useGetPersonSampleAudio() { + return useMutation({ + mutationFn: async (personId: string) => + unwrap(await ipc().speakers.getPersonSampleAudio(personId)), + }); +} +``` + +- [ ] **Step 4: Implement one coordinated People playback session** + +In `PeopleTab`, add `playingPersonId`, `playErrorPersonId`, `audioRef`, and `objectUrlRef` at tab scope. +Implement a stable `stopPlayback` callback that pauses audio, clears handlers, revokes the stored URL, clears refs, and resets the playing id. +Call it before starting another request and from the unmount effect. + +For each profile with `sample_available`, render an outline `Play`/`Stop` button before `Delete`. +On play, clear any prior fixed error, fetch base64, create a WAV blob URL, attach `onended` and `onerror` cleanup, call `audio.play()`, and set the active person only after play resolves. +Catch both IPC and media-play failures, clean up, and set only `playErrorPersonId`. +Render `Could not play this voice sample. Try again.` with `role="alert"` below that person's description. + +- [ ] **Step 5: Add the deterministic T1 mock** + +Reuse `MINIMAL_WAV_BASE64` for `get-person-sample-audio`. +Return `{ success: false, error: 'simulated private backend detail' }` when `STENOAI_E2E_PERSON_SAMPLE_FAIL=1` so the renderer test can prove raw errors never render. + +- [ ] **Step 6: Run the playback test and verify GREEN** + +Run the commands from Step 2. +Expected: the Play/Stop test passes with real media duration from the mock WAV. + +- [ ] **Step 7: Write and run a failing sanitized-error T1 test** + +Launch with `STENOAI_E2E_PERSON_SAMPLE_FAIL=1`, click Person Alpha's Play button, and assert the fixed error is visible while `simulated private backend detail` is absent from the page. +Run: + +```bash +npm run test:e2e -- --project=t1 --grep "People settings keeps voice sample failures private" +``` + +Expected before the error-state implementation is complete: failure on the fixed alert assertion. + +- [ ] **Step 8: Verify renderer and full People T1 coverage** + +Run: + +```bash +npm run typecheck:renderer +npm run lint:renderer -- --quiet +npm run test:unit +npm run build:renderer +npm run test:e2e -- --project=t1 --grep "People settings" +``` + +Expected: all commands pass. + +- [ ] **Step 9: Commit the UI slice** + +```bash +git add app/main.js app/preload.js app/renderer/src/lib/ipc.ts app/renderer/src/hooks/useSpeakerSuggestions.ts app/renderer/src/routes/settings/PeopleTab.tsx app/e2e-mock-ipc.js e2e/specs/speaker-review.t1.spec.ts +git commit -m "feat(settings): play a person voice sample" +``` + +--- + +### Task 3: Prove the real bundled playback path + +**Files:** +- Modify: `e2e/specs/people-management.t2.spec.ts` + +**Interfaces:** +- Consumes: `speakers.getPersonSampleAudio(personId)` and `PersonProfile.sample_available` from Tasks 1 and 2. +- Produces: one model-free T2 regression that exercises real config, sidecar, recording lookup, ffmpeg extraction, CLI, IPC, preload, and renderer-visible DTO shape. + +- [ ] **Step 1: Write the failing T2 test** + +In the isolated `userDataDir`, write a short 16 kHz mono synthetic WAV under `recordings/`, a matching current-run speaker sidecar under `output/`, and a config profile whose positive prototype points to the same meeting, channel, cluster, and run id. +Launch the app, call `listProfiles` and `getPersonSampleAudio` through `window.stenoai.speakers`, then assert: + +```typescript +expect(profile.sample_available).toBe(true); +expect(Object.keys(profile)).not.toEqual(expect.arrayContaining([ + 'meeting_id', 'channel', 'diarization_speaker_id', 'recording_path', 'prototypes', 'embedding', +])); +expect(result.success).toBe(true); +const bytes = Buffer.from(result.audio_base64!, 'base64'); +expect(bytes.subarray(0, 4).toString('ascii')).toBe('RIFF'); +expect(bytes.subarray(8, 12).toString('ascii')).toBe('WAVE'); +``` + +Also retain the existing `fileSig(realUserDataDir())` before/after assertion. + +- [ ] **Step 2: Build and verify RED against the old bundle** + +Run the new test before rebuilding the backend: + +```bash +cd app +npm run build:renderer +npm run test:e2e -- --project=t2 ../e2e/specs/people-management.t2.spec.ts --grep "plays a representative sample through the real backend" +``` + +Expected: failure because the current bundle predates the new CLI command or DTO field. + +- [ ] **Step 3: Rebuild the backend and verify GREEN** + +Run from repository root: + +```bash +venv/bin/pyinstaller stenoai.spec --noconfirm +``` + +Then rerun the T2 command from Step 2. +Expected: pass with a non-empty valid WAV payload and unchanged real user-data signature. + +- [ ] **Step 4: Commit the T2 slice** + +```bash +git add e2e/specs/people-management.t2.spec.ts +git commit -m "test(settings): cover person sample playback end to end" +``` + +--- + +### Task 4: Final release-readiness gate + +**Files:** +- Review only: all files changed since `aed7938`. + +**Interfaces:** +- Consumes the complete feature from Tasks 1 through 3. +- Produces a clean local branch with review findings addressed and no push. + +- [ ] **Step 1: Review the full feature diff** + +Run: + +```bash +git diff --check aed7938...HEAD +git diff --stat aed7938...HEAD +git diff aed7938...HEAD +``` + +Check privacy, cleanup on every playback exit, accessibility, stale provenance, Windows path behavior, raw error leakage, and accidental unrelated changes. + +- [ ] **Step 2: Obtain an independent read-only review** + +Ask a short-lived reviewer to inspect only `aed7938...HEAD` for correctness, privacy, accessibility, and platform parity. +Do not authorize edits or external actions. +Turn every verified issue into a failing test before changing production code. + +- [ ] **Step 3: Run the final verification matrix** + +Run: + +```bash +venv/bin/python -m unittest tests.test_person_sample_audio_cli tests.test_suggest_speakers_cli tests.test_person_profile_cli tests.test_transcriber_diarisation +venv/bin/ruff check src/transcriber.py tests/test_person_sample_audio_cli.py +cd app +npm run typecheck:renderer +npm run lint:renderer -- --quiet +npm run test:unit +npm run build:renderer +npm run test:e2e -- --project=t1 --grep-invert @perf +npm run test:e2e -- --project=t2 ../e2e/specs/people-management.t2.spec.ts ../e2e/specs/speaker-naming.t2.spec.ts ../e2e/specs/speaker-multi-marking.t2.spec.ts +npm run pack:unsigned +``` + +Expected: all scoped Python checks, renderer checks, Unit, T1, focused T2, backend build, and unsigned packaging pass. +If the known full Python discovery or project-wide Ruff baselines are checked, classify their pre-existing environment and style failures separately rather than claiming they are green. + +- [ ] **Step 4: Confirm clean branch state** + +Run: + +```bash +git status --short +git log --oneline aed7938..HEAD +``` + +Expected: no tracked or untracked task changes and no push performed. diff --git a/docs/superpowers/specs/2026-08-05-speaker-dominance-and-people-management-design.md b/docs/superpowers/specs/2026-08-05-speaker-dominance-and-people-management-design.md new file mode 100644 index 00000000..4bf54fcf --- /dev/null +++ b/docs/superpowers/specs/2026-08-05-speaker-dominance-and-people-management-design.md @@ -0,0 +1,55 @@ +# Speaker Dominance and People Management Design + +## Goal + +Keep sustained secondary speakers visible and labelable in long recordings while retaining the existing protection against short diarization blips. +Move global person-profile deletion into Settings and keep large profile libraries usable from the meeting review picker. + +## Dominance classification + +`CHANNEL_DOMINANCE_THRESHOLD` remains `0.92`. +The ratio gate continues to collapse ordinary single-speaker channels whose secondary clusters are only short diarization artifacts. + +Add an absolute floor of 15 seconds and reuse the validated 1.55-second minimum average turn length for a non-dominant cluster. +When a channel is at least 92 percent dominant but one or more minority clusters clear both gates, those sustained clusters remain distinct speakers. +Minority clusters below either floor inherit the dominant cluster's transcript label and do not become separate `Speaker N` entries. + +The label planner must also return the cluster IDs eligible for self-voiceprint matching. +This prevents a short folded blip from matching the owner's voiceprint and re-anchoring the entire mic channel. +If a sustained minority cluster matches the owner, folded blips continue to inherit the previous dominant cluster's replacement label rather than becoming their own speaker. + +## Review panel availability + +`is_diarised` describes whether the saved transcript contains more than one label. +The review panel's data source is the speaker sidecar, so that frontmatter flag is not sufficient to decide whether the panel has useful work. + +The panel queries speaker suggestions whenever it has a meeting stem. +Electron forwards each non-empty stem to the CLI, which reports a missing sidecar as a successful empty result. +The panel remains visible when the sidecar has at least one row for a diarised transcript or more than one row for a non-diarised transcript. +Zero-row results and non-diarised meetings with only one row show no review panel. + +## People management + +The existing `People` Settings tab remains the only UI location for deleting a person profile. +Deletion keeps the existing global-warning text because it removes recognition evidence across all meetings. +The meeting picker contains assignment actions only. + +The picker displays a search field at eight profiles or more. +Search remains substring-based, case-insensitive, and diacritic-insensitive while preserving the existing meeting-first ordering. +The list stays height-limited and scrollable. + +Deleting a profile invalidates the complete speaker query tree. +A meeting row that loses its assigned person becomes an unidentified filtered row and stays reachable through the existing filtered-row toggle after remounting. + +## Verification + +Python unit tests cover the sustained-minority case, the short-blip case, the fragmented-artifact case, the 1:1 regression shape, and self-voiceprint behavior with a folded blip. +A T1 test covers a non-diarised meeting whose sidecar still has multiple clusters. +A CLI test and a T2 test prove that a missing sidecar is an expected empty result rather than a backend failure. +A T1 test covers the large-library picker search and the absence of deletion controls there. +The existing deletion T1 flow moves through Settings and still proves that the meeting row no longer points at the deleted profile. +A T1 test covers accessible delete-button names and visible recovery from a failed deletion. +A model-free T2 test creates profiles through the real backend bridge, opens the People tab, checks the global warning, deletes a profile, and verifies `config.json` on disk. + +No test reads or writes the real user-data directory. +No production dependency is added. diff --git a/docs/superpowers/specs/2026-08-06-person-voice-sample-playback-design.md b/docs/superpowers/specs/2026-08-06-person-voice-sample-playback-design.md new file mode 100644 index 00000000..c0920770 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-person-voice-sample-playback-design.md @@ -0,0 +1,80 @@ +# Person Voice Sample Playback Design + +**Date:** 2026-08-06 + +**Branch:** `feat/speaker-people-management` + +## Goal + +Let someone verify a stored person profile by playing one representative voice sample from the People settings tab. +Keep the feature small enough for the current branch while meeting the existing privacy, accessibility, cross-platform, and end-to-end testing standards. + +## Scope + +Each person row may show one `Play` button next to `Delete`. +The button is present only when the backend can still produce audio from at least one confirmed positive prototype. +Starting playback changes the control to `Stop`. +Starting a different person's sample stops the current one first. + +This feature does not list or manage individual prototypes, expose their source meetings, enroll new samples, rename people, or retain extracted audio after playback. + +## Backend contract + +`list-person-profiles` adds a `sample_available` boolean to each profile DTO. +The boolean is true only when at least one positive prototype has all of the following: + +- A meeting id, channel, and diarization speaker id. +- A source recording that still exists. +- A matching speaker sidecar and cluster. +- A diarization run compatible with the prototype. +- At least one extractable segment with positive duration. + +A new read-only command, `get-person-sample-audio PERSON_ID`, resolves the profile again at click time and returns one WAV clip as base64. +Resolving again prevents a stale list response from authorizing playback after a recording or profile has disappeared. +The command returns a fixed structured failure when the person does not exist or no sample remains playable. + +The backend ranks playable positive prototypes by stored quality score, then by recency, then by stable provenance fields for deterministic ties. +It uses the existing recording lookup, sidecar validation, cluster resolution, and sample extraction helpers. +Hard negatives are never candidates. + +The renderer receives only `person_id`, the existing profile fields, `sample_available`, and the resulting audio payload. +Meeting ids, local paths, cluster ids, embeddings, and raw backend errors do not cross the IPC boundary. + +## IPC and renderer behavior + +Electron exposes a narrow `get-person-sample-audio` IPC handler and preload method. +The handler invokes the bundled CLI and sanitizes failures into the existing `Result` shape. + +The People tab keeps one active audio element for the whole list. +Clicking `Play` fetches the clip on demand, creates a temporary object URL, starts playback, and changes that row's control to `Stop`. +Playback ending, pressing `Stop`, switching people, unmounting the tab, or an error pauses the element and revokes the object URL. + +The control uses the visible labels `Play` and `Stop` with person-specific accessible names such as `Play voice sample for Person Alpha`. +While fetching, the clicked control shows a spinner and all sample controls are temporarily disabled so overlapping requests cannot create competing playback. +Delete remains visually separate and destructive. + +Profiles with stored embeddings but no remaining source recording keep their existing sample-count description and show no playback control. +A playback failure leaves the row intact and shows the fixed inline message `Could not play this voice sample. Try again.` +No raw backend error is rendered. + +## Privacy and safety + +Playback is local and user-triggered. +No audio, transcript text, meeting title, file path, embedding, or error body is sent to telemetry or another service. +The temporary WAV payload exists only in renderer memory for the playback session and is released afterward. +The command is read-only and never alters profiles, sidecars, transcripts, recordings, or configuration. + +## Cross-platform behavior + +Recording lookup and audio extraction reuse the existing Python helpers that already handle macOS and Windows paths and bundled ffmpeg. +The new Electron handler does not construct user-data paths. +Test fixtures continue to set `STENOAI_USER_DATA_DIR` so no real user data is read or changed. + +## Verification + +Python tests cover candidate eligibility, deterministic ranking, stale diarization runs, missing recordings, missing sidecars, hard-negative exclusion, and successful extraction. +Renderer unit tests cover the playback state transitions and cleanup behavior where those can be isolated without mocking the feature itself. +A T1 Playwright test covers button visibility, Play to Stop transitions, switching people, playback completion, and sanitized failure copy through mock IPC. +A model-free T2 test creates a synthetic WAV file, sidecar, and confirmed profile through the isolated real backend and verifies that People playback returns a valid non-empty WAV payload. + +Final verification reruns the affected Python suites, renderer typecheck and lint, unit tests, the full T1 suite, focused speaker T2 suites, the PyInstaller backend build, and unsigned Electron packaging. diff --git a/e2e/specs/people-management.t2.spec.ts b/e2e/specs/people-management.t2.spec.ts new file mode 100644 index 00000000..43fb7bfb --- /dev/null +++ b/e2e/specs/people-management.t2.spec.ts @@ -0,0 +1,165 @@ +import { test, expect } from '../fixtures/electron'; +import { realUserDataDir, fileSig } from '../fixtures/real-user-data'; +import { makeWav } from '../fixtures/make-wav'; +import { writeSpeakersSidecar } from '../fixtures/user-config'; +import { readFileSync, writeFileSync, mkdirSync } from 'fs'; +import path from 'path'; + +type ProfileMutationResult = { + success: boolean; + person_id?: string; + display_name?: string; + error?: string; +}; + +type StenoWindow = Window & { + stenoai: { + speakers: { + createProfile: (displayName: string) => Promise; + confirm: (params: { + meetingStem: string; + channel: string; + diarizationSpeakerId: string; + newPersonName: string; + }) => Promise; + listProfiles: () => Promise<{ + success: boolean; + person_profiles?: Array<{ + person_id: string; + display_name: string; + sample_available: boolean; + }>; + }>; + getPersonSampleAudio: ( + personId: string, + ) => Promise<{ success: boolean; error?: string; audio_base64?: string }>; + }; + }; +}; + +function storedProfileNames(configPath: string): string[] { + try { + const config = JSON.parse(readFileSync(configPath, 'utf8')) as { + person_profiles?: Array<{ display_name: string }>; + }; + return (config.person_profiles ?? []).map((profile) => profile.display_name).sort(); + } catch { + return []; + } +} + +test('People settings sorts profiles and deletes one through the real backend', async ({ + launchApp, + userDataDir, +}) => { + const realDirBefore = fileSig(realUserDataDir()); + const configPath = path.join(userDataDir, 'config.json'); + const { page } = await launchApp(); + + const zora = await page.evaluate(() => + (window as StenoWindow).stenoai.speakers.createProfile('Zora Quinn'), + ); + const ada = await page.evaluate(() => + (window as StenoWindow).stenoai.speakers.createProfile('Ada Lovelace'), + ); + expect(zora.success).toBe(true); + expect(ada.success).toBe(true); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + + const people = page.getByTestId('people-tab'); + await expect(people).toBeVisible(); + await expect(page.getByText('Deleting someone here removes their voice profile from every meeting.')).toBeVisible(); + + const rows = people.locator(':scope > div'); + await expect(rows).toHaveCount(2); + await expect(rows.nth(0)).toContainText('Ada Lovelace'); + await expect(rows.nth(1)).toContainText('Zora Quinn'); + await expect( + people.getByText('No voice samples yet - Steno cannot recognise them automatically.'), + ).toHaveCount(2); + + await page.getByTestId(`people-delete-${ada.person_id}`).click(); + const confirmDialog = page.locator('[data-confirm-dialog]'); + await expect(confirmDialog).toContainText('Delete Ada Lovelace?'); + await expect(confirmDialog).toContainText("This removes them from every meeting's speaker suggestions"); + await expect(confirmDialog).toContainText("This can't be undone."); + await confirmDialog.getByRole('button', { name: 'Delete' }).click(); + await expect(confirmDialog).toHaveCount(0); + + await expect.poll(() => storedProfileNames(configPath)).toEqual(['Zora Quinn']); + await expect(people).not.toContainText('Ada Lovelace'); + await expect(people).toContainText('Zora Quinn'); + + expect(fileSig(realUserDataDir())).toBe(realDirBefore); +}); + +test('People sample playback extracts a current confirmed voice through the bundled backend', async ({ + launchApp, + userDataDir, +}) => { + const realDirBefore = fileSig(realUserDataDir()); + const stem = 'e2e-person-sample'; + const recordingsDir = path.join(userDataDir, 'recordings'); + mkdirSync(recordingsDir, { recursive: true }); + makeWav(path.join(recordingsDir, `${stem}.wav`), { seconds: 3, channels: 2 }); + + const sidecarPath = writeSpeakersSidecar(userDataDir, stem, { + mic: { + recording_type: 'in_person', + clusters: { + SPEAKER_0: { + embedding: [1.0, 0.0], + speech_duration_seconds: 2.0, + segment_count: 1, + segments: [{ start: 0.5, end: 2.5 }], + }, + }, + }, + }); + const sidecar = JSON.parse(readFileSync(sidecarPath, 'utf8')) as Record; + sidecar.diarization_run = { run_id: 'e2e-person-sample-run', created_at: 1_700_000_000 }; + writeFileSync(sidecarPath, JSON.stringify(sidecar, null, 2)); + + const { page } = await launchApp(); + const confirmed = await page.evaluate( + (params) => (window as StenoWindow).stenoai.speakers.confirm(params), + { + meetingStem: stem, + channel: 'mic', + diarizationSpeakerId: 'SPEAKER_0', + newPersonName: 'Sample Person', + }, + ); + expect(confirmed.success).toBe(true); + + const profiles = await page.evaluate(() => + (window as StenoWindow).stenoai.speakers.listProfiles(), + ); + expect(profiles.success).toBe(true); + const profile = profiles.person_profiles?.find((entry) => entry.person_id === confirmed.person_id); + expect(profile).toMatchObject({ + display_name: 'Sample Person', + sample_available: true, + }); + expect(profile).not.toHaveProperty('meeting_id'); + expect(profile).not.toHaveProperty('channel'); + expect(profile).not.toHaveProperty('diarization_speaker_id'); + expect(profile).not.toHaveProperty('recording_path'); + expect(profile).not.toHaveProperty('prototypes'); + expect(profile).not.toHaveProperty('embedding'); + + const sample = await page.evaluate( + (personId) => (window as StenoWindow).stenoai.speakers.getPersonSampleAudio(personId), + confirmed.person_id as string, + ); + expect(sample.success).toBe(true); + const bytes = Buffer.from(sample.audio_base64 as string, 'base64'); + expect(bytes.subarray(0, 4).toString('ascii')).toBe('RIFF'); + expect(bytes.subarray(8, 12).toString('ascii')).toBe('WAVE'); + expect(bytes.length).toBeGreaterThan(44); + + expect(fileSig(realUserDataDir())).toBe(realDirBefore); +}); diff --git a/e2e/specs/speaker-naming.t2.spec.ts b/e2e/specs/speaker-naming.t2.spec.ts index d9fd4ccc..53f27459 100644 --- a/e2e/specs/speaker-naming.t2.spec.ts +++ b/e2e/specs/speaker-naming.t2.spec.ts @@ -41,6 +41,7 @@ type StenoWindow = Window & { suggestForMeeting: (meetingStem: string) => Promise<{ success: boolean; recording_available?: boolean; + minimum_speaker_count?: number; channels: Record< string, Record< @@ -72,6 +73,25 @@ type StenoWindow = Window & { const readJson = (file: string) => JSON.parse(readFileSync(file, 'utf8')); +test('a meeting without a speaker sidecar returns an empty result without backend failure', async ({ + launchApp, +}) => { + const realDirBefore = fileSig(realUserDataDir()); + const { page } = await launchApp(); + + const suggestions = await page.evaluate(() => + (window as StenoWindow).stenoai.speakers.suggestForMeeting('e2e-no-speaker-sidecar'), + ); + + expect(suggestions).toMatchObject({ + success: true, + recording_available: false, + minimum_speaker_count: 0, + channels: {}, + }); + expect(fileSig(realUserDataDir())).toBe(realDirBefore); +}); + test('confirm-speaker --relabel-transcript persists a PersonProfile and relabels the saved transcript', async ({ launchApp, userDataDir, diff --git a/e2e/specs/speaker-review.t1.spec.ts b/e2e/specs/speaker-review.t1.spec.ts index d179c8cb..0df0f3ef 100644 --- a/e2e/specs/speaker-review.t1.spec.ts +++ b/e2e/specs/speaker-review.t1.spec.ts @@ -13,13 +13,46 @@ import type { Page } from '@playwright/test'; const SUMMARY_FILE = 'speaker-review-mtg_summary.json'; -async function openDetail(page: Page) { +async function navigateToDetail(page: Page) { await page.evaluate((f) => { window.location.hash = `#/meetings/${encodeURIComponent(f)}`; }, SUMMARY_FILE); + await expect(page.getByTestId('meeting-detail-title')).toContainText('Speaker Review Meeting'); +} + +async function openDetail(page: Page) { + await navigateToDetail(page); await expect(page.getByTestId('speaker-review-panel')).toBeVisible(); } +test('sidecar has multiple clusters opens review even when the transcript is not diarised', async ({ + launchApp, +}) => { + // This catches a gate based only on is_diarised: the sidecar's separate + // clusters are still actionable even though their transcript labels stay + // generic. + const { page } = await launchApp({ + mockIpc: true, + env: { STENOAI_E2E_SEED_SPEAKER_SIDECAR: '1' }, + }); + + await openDetail(page); + await expect(page.locator('[data-testid^="speaker-row-"]').nth(1)).toBeVisible(); +}); + +test('a single sidecar cluster remains reviewable when the transcript is diarised', async ({ + launchApp, +}) => { + const { page } = await launchApp({ + mockIpc: true, + env: { STENOAI_E2E_SEED_SPEAKER_SINGLE_CLUSTER: '1' }, + }); + + await openDetail(page); + await expect(page.getByTestId('speaker-row-mic:SPEAKER_0')).toBeVisible(); + await expect(page.locator('[data-testid^="speaker-row-"]').nth(1)).toHaveCount(0); +}); + test('Approve confirms the suggested person for a "confirmed"-tier row', async ({ launchApp }) => { const { page } = await launchApp({ mockIpc: true, @@ -108,7 +141,35 @@ test('New person blocks creating a duplicate of an existing person', async ({ la await expect(page.getByTestId('speaker-new-person-submit')).toBeEnabled(); }); -test('a person profile can be deleted from the Change popover, unwinding any row confirmed as them', async ({ +test('searches a large people library without exposing deletion in the meeting picker', async ({ + launchApp, +}) => { + const { page } = await launchApp({ + mockIpc: true, + env: { + STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1', + STENOAI_E2E_SEED_MANY_PEOPLE: '1', + }, + }); + await openDetail(page); + + const row = page.getByTestId('speaker-row-mic:SPEAKER_0'); + await row.getByRole('button', { name: 'Change' }).click(); + + const search = page.getByTestId('speaker-person-search-mic:SPEAKER_0'); + await expect(search).toBeVisible(); + await expect(page.locator('[data-testid^="speaker-pick-person-"]')).toHaveCount(10); + await expect(page.locator('[data-testid^="speaker-delete-person-"]')).toHaveCount(0); + + await search.fill('Zora'); + await expect(page.getByRole('button', { name: 'Zora Quinn', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Person Alpha', exact: true })).toHaveCount(0); + + await search.fill('not in this library'); + await expect(page.getByTestId('speaker-person-no-match')).toHaveText('No match'); +}); + +test('People settings deletion unwinds a confirmed meeting row', async ({ launchApp, }) => { const { page } = await launchApp({ @@ -121,23 +182,151 @@ test('a person profile can be deleted from the Change popover, unwinding any row await row.getByRole('button', { name: 'Approve' }).click(); await expect(row).toContainText('✓ Confirmed as Person Alpha'); - await row.getByRole('button', { name: 'Change' }).click(); - await page.getByTestId('speaker-delete-person-p-alpha').click(); + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + await expect(page.getByText('Deleting someone here removes their voice profile from every meeting.')).toBeVisible(); + await page.getByTestId('people-delete-p-alpha').click(); const confirmDialog = page.locator('[data-confirm-dialog]'); await expect(confirmDialog).toContainText('Delete Person Alpha?'); + await expect(confirmDialog).toContainText("This removes them from every meeting's speaker suggestions"); + await expect(confirmDialog).toContainText("This can't be undone."); await confirmDialog.getByRole('button', { name: 'Delete' }).click(); await expect(confirmDialog).toHaveCount(0); - // The deleted person's evidence is gone -- the cluster that was - // confirmed as them reverts to unidentified, not left pointing at a - // person that no longer exists. - await expect(row).toContainText('Unidentified speaker'); - await expect(row).not.toContainText('Person Alpha'); + await openDetail(page); + await page.getByTestId('speaker-toggle-filtered').click(); + const revertedRow = page.getByTestId('speaker-row-mic:SPEAKER_0'); + await expect(revertedRow).toBeVisible(); + await expect(revertedRow).toContainText('Unidentified speaker'); + await expect(revertedRow).not.toContainText('Person Alpha'); +}); - // And they're gone from the Change list too. - await row.getByRole('button', { name: 'Change' }).click(); - await expect(page.getByRole('button', { name: 'Person Alpha', exact: true })).toHaveCount(0); +test('People settings delete buttons identify the affected person', async ({ launchApp }) => { + const { page } = await launchApp({ + mockIpc: true, + env: { STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1' }, + }); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + + await expect(page.getByRole('button', { name: 'Delete Person Alpha', exact: true })).toBeVisible(); + await expect(page.getByRole('button', { name: 'Delete Person Beta', exact: true })).toBeVisible(); +}); + +test('People settings plays one representative voice sample', async ({ launchApp }) => { + const { page } = await launchApp({ + mockIpc: true, + env: { STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1' }, + }); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + + const play = page.getByRole('button', { name: 'Play voice sample for Person Alpha' }); + await expect(play).toBeVisible(); + await expect( + page.getByRole('button', { name: 'Play voice sample for Person Beta' }), + ).toHaveCount(0); + + await play.click(); + const stop = page.getByRole('button', { name: 'Stop voice sample for Person Alpha' }); + await expect(stop).toBeVisible(); + await stop.click(); + await expect(play).toBeVisible(); +}); + +test('People settings returns to Play without an error when a voice sample ends', async ({ launchApp }) => { + const { page } = await launchApp({ + mockIpc: true, + env: { STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1' }, + }); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + + const play = page.getByRole('button', { name: 'Play voice sample for Person Alpha' }); + await play.click(); + await expect(page.getByRole('button', { name: 'Stop voice sample for Person Alpha' })).toBeVisible(); + await expect(play).toBeVisible({ timeout: 8_000 }); + await expect(page.getByTestId('people-play-error-p-alpha')).toHaveCount(0); +}); + +test('People settings reports a media error after playback has started', async ({ launchApp }) => { + const { page } = await launchApp({ + mockIpc: true, + env: { STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1' }, + }); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + await page.evaluate(() => { + HTMLMediaElement.prototype.play = function () { + window.setTimeout(() => this.dispatchEvent(new Event('error')), 50); + return Promise.resolve(); + }; + }); + + await page.getByRole('button', { name: 'Play voice sample for Person Alpha' }).click(); + await expect(page.getByTestId('people-play-error-p-alpha')).toHaveText( + 'Could not play this voice sample. Try again.', + ); +}); + +test('People settings keeps voice sample failures private', async ({ launchApp }) => { + const { page } = await launchApp({ + mockIpc: true, + env: { + STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1', + STENOAI_E2E_PERSON_SAMPLE_FAIL: '1', + }, + }); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + await page.getByTestId('people-play-p-alpha').click(); + + await expect(page.getByTestId('people-play-error-p-alpha')).toHaveText( + 'Could not play this voice sample. Try again.', + ); + await expect(page.getByText('simulated private backend detail')).toHaveCount(0); +}); + +test('People settings deletion failure stays visible and keeps the profile', async ({ launchApp }) => { + const { page } = await launchApp({ + mockIpc: true, + env: { + STENOAI_E2E_SEED_SPEAKER_SUGGESTIONS: '1', + STENOAI_E2E_DELETE_PERSON_FAIL: '1', + }, + }); + + await page.evaluate(() => { + window.location.hash = '#/settings?tab=people'; + }); + await expect(page.getByTestId('people-tab')).toBeVisible(); + await page.getByTestId('people-delete-p-alpha').click(); + + const confirmDialog = page.locator('[data-confirm-dialog]'); + await confirmDialog.getByRole('button', { name: 'Delete' }).click(); + await expect(confirmDialog).toBeVisible(); + await expect(page.getByTestId('people-delete-error')).toHaveText( + 'Could not delete this person. Try again.', + ); + await expect(page.getByTestId('people-tab')).toContainText('Person Alpha'); }); // Replaces 'Keep generic dismisses the row locally, no confirm call needed', @@ -326,7 +515,7 @@ test('confirming one row disables every OTHER row\'s actions too, not just the o await expect(rowB.getByRole('button', { name: 'Change' })).toBeEnabled(); }); -test('a likely-artifact row is hidden by default, reachable via the filtered-rows toggle', async ({ +test('filtered rows remain reachable through the filtered-rows toggle', async ({ launchApp, }) => { const { page } = await launchApp({ @@ -335,16 +524,19 @@ test('a likely-artifact row is hidden by default, reachable via the filtered-row }); await openDetail(page); + await expect(page.getByTestId('speaker-row-mic:SPEAKER_3')).toHaveCount(0); await expect(page.getByTestId('speaker-row-mic:SPEAKER_4')).toHaveCount(0); const toggle = page.getByTestId('speaker-toggle-filtered'); - await expect(toggle).toHaveText('Show 1 filtered row'); + await expect(toggle).toHaveText('Show 2 filtered rows'); await toggle.click(); + await expect(page.getByTestId('speaker-row-mic:SPEAKER_3')).toBeVisible(); await expect(page.getByTestId('speaker-row-mic:SPEAKER_4')).toBeVisible(); await expect(toggle).toHaveText('Hide filtered rows'); await toggle.click(); + await expect(page.getByTestId('speaker-row-mic:SPEAKER_3')).toHaveCount(0); await expect(page.getByTestId('speaker-row-mic:SPEAKER_4')).toHaveCount(0); }); diff --git a/simple_recorder.py b/simple_recorder.py index fc8117d7..7c9e077a 100644 --- a/simple_recorder.py +++ b/simple_recorder.py @@ -4860,8 +4860,9 @@ def list_person_profiles(): Testing surface for the human-confirmed speaker-suggestion feature (see src.speaker_suggestions) before any approval UI exists — mirrors the existing template/voiceprint CLI commands in shape.""" - from src.config import get_config + from src.config import get_config, get_data_dirs profiles = get_config().get_person_profiles() + dirs = get_data_dirs() def _counts_by_context(entries): counts: dict = {} @@ -4877,6 +4878,7 @@ def _counts_by_context(entries): "display_name": p.get("display_name"), "prototype_counts": _counts_by_context(p.get("prototypes") or []), "hard_negative_counts": _counts_by_context(p.get("hard_negatives") or []), + "sample_available": _resolve_person_sample(p, dirs) is not None, "updated_at": p.get("updated_at"), } for p in profiles @@ -4884,6 +4886,172 @@ def _counts_by_context(entries): })) +def _resolve_person_sample(profile: dict, dirs: dict) -> Optional[dict]: + """Return the best currently playable positive prototype for a person. + + The returned provenance stays inside Python. Renderer-facing profile + listings expose only whether a sample is available, and the playback + command resolves this again at click time so a deleted recording or a + replaced diarization run cannot be played through stale UI state. + """ + from src.speaker_suggestions import ( + clusters_from_sidecar_channel, + merge_same_channel_fragments, + prototype_run_matches, + read_speakers_sidecar, + ) + + if not isinstance(profile, dict): + return None + + prototypes = profile.get("prototypes") or [] + if not isinstance(prototypes, list): + return None + + candidates = [] + for prototype in prototypes: + if not isinstance(prototype, dict): + continue + meeting_id = prototype.get("meeting_id") + channel = prototype.get("channel") + speaker_id = prototype.get("diarization_speaker_id") + if not all(isinstance(value, str) and value for value in (meeting_id, channel, speaker_id)): + continue + + sidecar = read_speakers_sidecar(Path(dirs["output"]), meeting_id) + if not isinstance(sidecar, dict): + continue + diarization_run = sidecar.get("diarization_run") or {} + if not isinstance(diarization_run, dict): + continue + sidecar_run_id = diarization_run.get("run_id") + if not prototype_run_matches(prototype, sidecar_run_id): + continue + + channels = sidecar.get("channels") or {} + if not isinstance(channels, dict): + continue + channel_data = channels.get(channel) + if not isinstance(channel_data, dict): + continue + raw_clusters_by_id = channel_data.get("clusters") or {} + if not isinstance(raw_clusters_by_id, dict): + continue + valid_clusters = { + sid: cluster + for sid, cluster in raw_clusters_by_id.items() + if isinstance(sid, str) + and isinstance(cluster, dict) + and isinstance(cluster.get("embedding"), list) + and bool(cluster["embedding"]) + and all(isinstance(value, (int, float)) for value in cluster["embedding"]) + } + safe_channel_data = {**channel_data, "clusters": valid_clusters} + raw_clusters = clusters_from_sidecar_channel(meeting_id, safe_channel_data) + if speaker_id not in raw_clusters: + continue + try: + clusters, id_resolution = merge_same_channel_fragments(raw_clusters) + except (IndexError, TypeError, ValueError, ZeroDivisionError): + continue + resolved_id = id_resolution.get(speaker_id) + if resolved_id not in clusters: + continue + context = clusters[resolved_id][1] + + pooled_segments = [ + segment + for fragment_id in [resolved_id, *context.merged_from] + for segment in (raw_clusters_by_id.get(fragment_id, {}).get("segments") or []) + if isinstance(segment, dict) + and isinstance(segment.get("start"), (int, float)) + and isinstance(segment.get("end"), (int, float)) + and segment["end"] > segment["start"] + ] + if not pooled_segments: + continue + + recording_path = _find_recording_file(Path(dirs["recordings"]), meeting_id) + if recording_path is None: + continue + + def _number(value) -> float: + try: + return float(value) + except (TypeError, ValueError): + return 0.0 + + candidates.append({ + "meeting_id": meeting_id, + "channel": channel, + "diarization_speaker_id": speaker_id, + "recording_path": recording_path, + "pooled_segments": pooled_segments, + "quality_score": _number(prototype.get("quality_score")), + "created_at": _number(prototype.get("created_at")), + }) + + if not candidates: + return None + return min( + candidates, + key=lambda candidate: ( + -candidate["quality_score"], + -candidate["created_at"], + candidate["meeting_id"], + candidate["channel"], + candidate["diarization_speaker_id"], + ), + ) + + +@cli.command(name="get-person-sample-audio") +@click.argument("person_id") +def get_person_sample_audio(person_id): + """Return one representative, currently playable clip for a person. + + Profile provenance stays private to the backend. Missing people, stale + sidecars, removed recordings, and extraction failures intentionally share + one fixed response so local meeting and filesystem details cannot leak to + the renderer through an error message. + """ + import base64 + import tempfile + + from src.config import get_config, get_data_dirs + from src.speaker_suggestions import extract_speaker_sample_audio + + profile = get_config().get_person_profile(person_id) + sample = _resolve_person_sample(profile, get_data_dirs()) if profile else None + if sample is None: + print(json.dumps({"success": False, "error": "voice sample unavailable"})) + return + + output_path = ( + Path(tempfile.gettempdir()) + / f"steno_person_sample_{os.getpid()}_{time.time_ns()}.wav" + ) + try: + ok = extract_speaker_sample_audio( + sample["recording_path"], + sample["channel"], + sample["pooled_segments"], + output_path, + ) + if not ok: + print(json.dumps({"success": False, "error": "voice sample unavailable"})) + return + audio_bytes = output_path.read_bytes() + print(json.dumps({ + "success": True, + "audio_base64": base64.b64encode(audio_bytes).decode("ascii"), + })) + except (OSError, ValueError): + print(json.dumps({"success": False, "error": "voice sample unavailable"})) + finally: + output_path.unlink(missing_ok=True) + + @cli.command(name='create-person-profile') @click.argument('display_name') def create_person_profile(display_name): @@ -5743,11 +5911,13 @@ def suggest_speakers(meeting_stem): sidecar = read_speakers_sidecar(dirs["output"], meeting_stem) if sidecar is None: print(json.dumps({ - "success": False, - "error": f"No speakers sidecar found for {meeting_stem!r} — run the backfill " - "command first, or record a new meeting.", + "success": True, + "meeting_id": meeting_stem, + "recording_available": False, + "minimum_speaker_count": 0, + "channels": {}, })) - sys.exit(1) + return # Whether a play button can appear at all -- checked once per meeting, # not per cluster, since it's the same source recording either way. diff --git a/src/transcriber.py b/src/transcriber.py index 4c3a7216..190bb525 100644 --- a/src/transcriber.py +++ b/src/transcriber.py @@ -42,7 +42,11 @@ from typing import Callable, Optional, Tuple from src._heartbeat import _emit_heartbeat -from src.speaker_suggestions import build_clusters_from_diarization, determine_recording_type +from src.speaker_suggestions import ( + SUGGESTION_MIN_AVG_TURN_SECONDS, + build_clusters_from_diarization, + determine_recording_type, +) logger = logging.getLogger(__name__) @@ -138,14 +142,14 @@ STENO_DIARIZE_TIMEOUT_FLOOR_S = 120 # If one diarizer cluster holds this share (or more) of a channel's total -# speaking time, the channel is treated as single-speaker — any other -# cluster is almost certainly a brief misdiarization blip (observed -# empirically: short/overlapping noise segments from Sortformer on -# single-mic audio), not a real second speaker. Gates both the legacy -# "Speaker N" placeholder path (_cluster_channel_labels) and voiceprint -# matching, since a spurious cluster shouldn't get embedded and matched -# either. +# speaking time, short minority clusters are folded into the dominant +# speaker's transcript label. Sustained minority clusters still remain +# distinct after clearing both the cumulative-duration floor and the existing +# calibrated average-turn floor used by speaker suggestions. This keeps many +# tiny echo/crosstalk fragments from adding up to a false additional speaker. CHANNEL_DOMINANCE_THRESHOLD = 0.92 +CHANNEL_DOMINANCE_MIN_MINOR_SPEECH_SECONDS = 15.0 +CHANNEL_DOMINANCE_MIN_AVG_TURN_SECONDS = SUGGESTION_MIN_AVG_TURN_SECONDS # Sentinel text substituted when transcription produces no usable output # (genuine silence or all-hallucination). Callers compare against this to @@ -959,32 +963,69 @@ def _worst_window_coverage(*results: Optional[dict]) -> Optional[float]: return min(values) if values else None -def _cluster_channel_labels(diar_segments: list[dict], legacy_label: str) -> Optional[dict[str, str]]: - """Map each diarizer speaker id in diar_segments to either the channel's - legacy label (the cluster with the most total speaking time) or a - placeholder key for every other cluster, later resolved to "Speaker N" - by _resolve_speaker_placeholders. - - Returns None when diar_segments contains a single (or zero) distinct - speaker, OR when one cluster's share of total speaking time is at or - above CHANNEL_DOMINANCE_THRESHOLD — the byte-identical-to-legacy fast - path, since a barely-there second cluster is almost certainly - misdiarization noise rather than a real second speaker. +def _cluster_channel_label_plan( + diar_segments: list[dict], legacy_label: str, +) -> tuple[Optional[dict[str, str]], set[str]]: + """Return transcript labels and clusters eligible for self matching. + + A channel at or beyond the dominance ratio remains legacy-labeled unless + at least one minority cluster has enough total speech and a sufficiently + long average turn to be a sustained second speaker. Short or fragmented + clusters fold into the dominant label and are excluded from self-voiceprint + matching, so diarization artifacts cannot re-anchor the channel or acquire + their own speaker label. """ - speaker_ids = {s["speaker"] for s in diar_segments} - if len(speaker_ids) <= 1: - return None - totals: dict[str, float] = {sid: 0.0 for sid in speaker_ids} + totals: dict[str, float] = {} + turn_counts: dict[str, int] = {} for s in diar_segments: - totals[s["speaker"]] += s["end"] - s["start"] + sid = s["speaker"] + totals[sid] = totals.get(sid, 0.0) + s["end"] - s["start"] + turn_counts[sid] = turn_counts.get(sid, 0) + 1 + if len(totals) <= 1: + return None, set() + dominant = max(totals, key=totals.get) total_time = sum(totals.values()) - if total_time > 0 and totals[dominant] / total_time >= CHANNEL_DOMINANCE_THRESHOLD: - return None - return { - sid: (legacy_label if sid == dominant else f"__diar__{legacy_label}__{sid}") - for sid in speaker_ids + if total_time <= 0 or totals[dominant] / total_time < CHANNEL_DOMINANCE_THRESHOLD: + return ( + { + sid: (legacy_label if sid == dominant else f"__diar__{legacy_label}__{sid}") + for sid in totals + }, + set(totals), + ) + + sustained_minorities = { + sid + for sid, speech_seconds in totals.items() + if sid != dominant + and speech_seconds >= CHANNEL_DOMINANCE_MIN_MINOR_SPEECH_SECONDS + and speech_seconds / turn_counts[sid] >= CHANNEL_DOMINANCE_MIN_AVG_TURN_SECONDS } + if not sustained_minorities: + return None, set() + + return ( + { + sid: ( + legacy_label + if sid == dominant or sid not in sustained_minorities + else f"__diar__{legacy_label}__{sid}" + ) + for sid in totals + }, + {dominant, *sustained_minorities}, + ) + + +def _cluster_channel_labels(diar_segments: list[dict], legacy_label: str) -> Optional[dict[str, str]]: + """Return the transcript-label portion of _cluster_channel_label_plan. + + This public compatibility wrapper preserves callers that only need the + cluster-to-label mapping; new diarization code also consumes the plan's + self-voiceprint eligibility set. + """ + return _cluster_channel_label_plan(diar_segments, legacy_label)[0] # Cosine-distance threshold + confidence margin for voiceprint matching @@ -1022,6 +1063,7 @@ def _apply_voiceprint_matches( cluster_labels: dict[str, str], legacy_label: str, allow_self_match: bool, + eligible_speaker_ids: Optional[set[str]] = None, ) -> dict[str, str]: """Override cluster_labels with a self-voiceprint match where found. @@ -1069,17 +1111,33 @@ def _apply_voiceprint_matches( best_sid, best_dist = None, VOICEPRINT_DISTANCE_THRESHOLD for sid, emb in speaker_embeddings.items(): + if eligible_speaker_ids is not None and sid not in eligible_speaker_ids: + continue dist = _voiceprint_distance(emb, self_vp) if dist < best_dist: best_sid, best_dist = sid, dist you_cluster = best_sid if you_cluster is not None: + old_dominant = next( + ( + sid + for sid, label in updated.items() + if label == legacy_label + and (eligible_speaker_ids is None or sid in eligible_speaker_ids) + ), + None, + ) + old_dominant_placeholder = ( + f"__diar__{legacy_label}__{old_dominant}" if old_dominant is not None else None + ) + if you_cluster == old_dominant: + return updated for sid in updated: if sid == you_cluster: updated[sid] = legacy_label elif updated[sid] == legacy_label: - updated[sid] = f"__diar__{legacy_label}__{sid}" + updated[sid] = old_dominant_placeholder or f"__diar__{legacy_label}__{sid}" return updated @@ -1127,10 +1185,11 @@ def _tag_channel_segments( diarizer's own segment boundaries (with ASR sentences reassigned into them via _assign_asr_segments_to_diar_segments). The cluster with the most total speaking time keeps the channel's legacy label - ("You"/"Others"); every other cluster gets a placeholder resolved to - "Speaker N" later — unless the self voiceprint matches a different - cluster, in which case the legacy label re-anchors onto that cluster - instead (see _apply_voiceprint_matches; mic channel only). NAMED + ("You"/"Others"). Other clusters get a placeholder resolved to + "Speaker N" later, except short minority clusters in a dominant channel, + which fold into the dominant label. A self voiceprint can re-anchor the + legacy label onto a different eligible cluster instead (see + _apply_voiceprint_matches; mic channel only). NAMED (non-self) speaker identification does not happen automatically here — see src.speaker_suggestions for the human-confirmed suggestion flow. ANY failure — missing binary, timeout, bad JSON, a single-cluster @@ -1150,10 +1209,9 @@ def _tag_channel_segments( return [] # Set below whenever diarization ran and produced real diar_segments - # but _cluster_channel_labels decided NOT to split the transcript - # (single real speaker, or multiple ids where one dominates >= - # CHANNEL_DOMINANCE_THRESHOLD -- a normal 1:1 call's remote side is - # very often exactly this shape). legacy_tagged below still looks up + # but _cluster_channel_label_plan decided NOT to split the transcript + # (a single real speaker, or no sustained minority in a channel at or + # beyond CHANNEL_DOMINANCE_THRESHOLD). legacy_tagged below still looks up # each ASR segment's OWN nearest diar segment for exact per-line # provenance -- unlike single_raw_sid's earlier, cruder approach # (claiming ONE id for the whole legacy-labeled span), this stays @@ -1174,10 +1232,16 @@ def _progress_sink(i: int, n: int, _label=legacy_label) -> None: diarize_result = _run_steno_diarize(channel_path, timeout, progress_sink=_progress_sink) if diarize_result: diar_segments, speaker_embeddings = diarize_result - cluster_labels = _cluster_channel_labels(diar_segments, legacy_label) + cluster_labels, eligible_speaker_ids = _cluster_channel_label_plan( + diar_segments, legacy_label, + ) if cluster_labels: cluster_labels = _apply_voiceprint_matches( - speaker_embeddings, cluster_labels, legacy_label, allow_self_match, + speaker_embeddings, + cluster_labels, + legacy_label, + allow_self_match, + eligible_speaker_ids, ) unplaceable = _assign_asr_segments_to_diar_segments(asr_segments, diar_segments) diar_tagged = [] @@ -1211,11 +1275,11 @@ def _progress_sink(i: int, n: int, _label=legacy_label) -> None: ) return diar_tagged else: - # _cluster_channel_labels returned None: either a - # genuinely single distinct diarizer id, or multiple - # ids where one dominates >= CHANNEL_DOMINANCE_THRESHOLD - # (a barely-there second cluster, treated as noise) -- - # not a diarization failure either way. The TRANSCRIPT + # _cluster_channel_label_plan returned no labels: either + # a genuinely single distinct diarizer id, or no + # sustained minority in a channel at or beyond + # CHANNEL_DOMINANCE_THRESHOLD -- not a diarization + # failure either way. The TRANSCRIPT # correctly falls back to plain legacy_label (no # "Speaker N" split needed for one continuous voice) -- # but real diar_segments exist, so legacy_tagged below diff --git a/tests/test_audio_preprocess.py b/tests/test_audio_preprocess.py index 3c81889d..0d474da2 100644 --- a/tests/test_audio_preprocess.py +++ b/tests/test_audio_preprocess.py @@ -152,7 +152,8 @@ def test_temp_cleaned_when_backend_crashes(self): temp = Path(tmp_dir) / "stenoai_prep_meeting.wav" temp.write_bytes(b"\x00" * 2048) with patch.object(transcriber, "_preprocess_audio", return_value=(temp, True)), \ - patch.object(transcriber, "_run_backend", side_effect=RuntimeError("boom")): + patch.object(transcriber, "_run_backend", side_effect=RuntimeError("boom")), \ + patch.object(transcriber, "_build_whisper_fallback", return_value=False): out = transcriber.transcribe_audio(audio, language="en") self.assertTrue(out.get("transcription_failed")) self.assertFalse(temp.exists()) diff --git a/tests/test_person_sample_audio_cli.py b/tests/test_person_sample_audio_cli.py new file mode 100644 index 00000000..793165a4 --- /dev/null +++ b/tests/test_person_sample_audio_cli.py @@ -0,0 +1,288 @@ +import base64 +import json +import tempfile +import unittest +import wave +from pathlib import Path +from unittest import mock + +from click.testing import CliRunner + +import simple_recorder +from simple_recorder import _resolve_person_sample +from src.config import Config +from src.speaker_suggestions import read_speakers_sidecar, write_speakers_sidecar + + +def _write_wav(path: Path, seconds: float = 2.0) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + with wave.open(str(path), "wb") as wav: + wav.setnchannels(1) + wav.setsampwidth(2) + wav.setframerate(16_000) + wav.writeframes(b"\0\0" * int(16_000 * seconds)) + + +def _write_sidecar( + output_dir: Path, + meeting_id: str, + *, + channel: str = "system", + speaker_id: str = "SPEAKER_0", + segments: list[dict] | None = None, +) -> str: + output_dir.mkdir(parents=True, exist_ok=True) + write_speakers_sidecar(output_dir, meeting_id, { + channel: { + "recording_type": "remote" if channel == "system" else "in_person", + "clusters": { + speaker_id: { + "embedding": [1.0, 0.0], + "speech_duration_seconds": 30.0, + "segment_count": 4, + "segments": segments if segments is not None else [ + {"start": 0.25, "end": 1.75}, + ], + }, + }, + }, + }) + return read_speakers_sidecar(output_dir, meeting_id)["diarization_run"]["run_id"] + + +def _prototype( + meeting_id: str, + run_id: str | None, + *, + quality: float = 1.0, + created_at: float = 1.0, + channel: str = "system", + speaker_id: str = "SPEAKER_0", +) -> dict: + return { + "prototype_id": f"proto-{meeting_id}", + "embedding_mean": [1.0, 0.0], + "recording_type": "remote" if channel == "system" else "in_person", + "meeting_id": meeting_id, + "channel": channel, + "diarization_speaker_id": speaker_id, + "diarization_run_id": run_id, + "quality_score": quality, + "created_at": created_at, + } + + +class PersonSampleResolutionTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.dirs = { + "output": self.root / "output", + "recordings": self.root / "recordings", + "transcripts": self.root / "transcripts", + } + + def tearDown(self): + self.temp.cleanup() + + def _playable_prototype(self, meeting_id: str, **overrides) -> dict: + run_id = _write_sidecar(self.dirs["output"], meeting_id) + _write_wav(self.dirs["recordings"] / f"{meeting_id}.wav") + return _prototype(meeting_id, run_id, **overrides) + + def test_selects_highest_quality_current_positive_prototype(self): + lower = self._playable_prototype("lower", quality=0.4, created_at=20.0) + higher = self._playable_prototype("higher", quality=0.9, created_at=10.0) + profile = {"person_id": "p1", "prototypes": [lower, higher], "hard_negatives": []} + + sample = _resolve_person_sample(profile, self.dirs) + + self.assertEqual(sample["meeting_id"], "higher") + self.assertEqual(sample["channel"], "system") + self.assertEqual(sample["diarization_speaker_id"], "SPEAKER_0") + self.assertEqual(sample["pooled_segments"], [{"start": 0.25, "end": 1.75}]) + + def test_uses_recency_then_stable_provenance_for_ties(self): + older = self._playable_prototype("older", quality=1.0, created_at=1.0) + newer = self._playable_prototype("newer", quality=1.0, created_at=2.0) + profile = {"person_id": "p1", "prototypes": [older, newer], "hard_negatives": []} + self.assertEqual(_resolve_person_sample(profile, self.dirs)["meeting_id"], "newer") + + alpha = self._playable_prototype("alpha", quality=1.0, created_at=2.0) + beta = self._playable_prototype("beta", quality=1.0, created_at=2.0) + profile["prototypes"] = [beta, alpha] + self.assertEqual(_resolve_person_sample(profile, self.dirs)["meeting_id"], "alpha") + + def test_rejects_stale_diarization_run(self): + _write_sidecar(self.dirs["output"], "stale") + _write_wav(self.dirs["recordings"] / "stale.wav") + profile = { + "person_id": "p1", + "prototypes": [_prototype("stale", "older-run")], + "hard_negatives": [], + } + + self.assertIsNone(_resolve_person_sample(profile, self.dirs)) + + def test_rejects_missing_recording_sidecar_channel_cluster_and_segments(self): + no_recording_run = _write_sidecar(self.dirs["output"], "no-recording") + + _write_wav(self.dirs["recordings"] / "no-sidecar.wav") + + no_channel_run = _write_sidecar( + self.dirs["output"], "no-channel", channel="mic", + ) + _write_wav(self.dirs["recordings"] / "no-channel.wav") + + no_cluster_run = _write_sidecar( + self.dirs["output"], "no-cluster", speaker_id="SPEAKER_1", + ) + _write_wav(self.dirs["recordings"] / "no-cluster.wav") + + no_segments_run = _write_sidecar( + self.dirs["output"], "no-segments", segments=[], + ) + _write_wav(self.dirs["recordings"] / "no-segments.wav") + + cases = { + "recording": _prototype("no-recording", no_recording_run), + "sidecar": _prototype("no-sidecar", None), + "channel": _prototype("no-channel", no_channel_run), + "cluster": _prototype("no-cluster", no_cluster_run), + "segments": _prototype("no-segments", no_segments_run), + } + for missing, prototype in cases.items(): + with self.subTest(missing=missing): + profile = {"person_id": "p1", "prototypes": [prototype], "hard_negatives": []} + self.assertIsNone(_resolve_person_sample(profile, self.dirs)) + + def test_ignores_hard_negatives(self): + negative = self._playable_prototype("negative") + profile = {"person_id": "p1", "prototypes": [], "hard_negatives": [negative]} + + self.assertIsNone(_resolve_person_sample(profile, self.dirs)) + + +class PersonSampleCliTests(unittest.TestCase): + def setUp(self): + self.temp = tempfile.TemporaryDirectory() + self.root = Path(self.temp.name) + self.output_dir = self.root / "output" + self.recordings_dir = self.root / "recordings" + self.config = Config(config_path=self.root / "config.json") + + def tearDown(self): + self.temp.cleanup() + + def _run(self, command, args): + with mock.patch("src.config.get_config", return_value=self.config), \ + mock.patch.dict("os.environ", {"STENOAI_USER_DATA_DIR": str(self.root)}): + return CliRunner().invoke(command, args) + + def _seed_playable_person(self, name="Person Alpha") -> dict: + profile = self.config.create_person_profile(name) + run_id = _write_sidecar(self.output_dir, "meeting") + _write_wav(self.recordings_dir / "meeting.wav") + self.config.add_speaker_prototype( + profile["person_id"], + [1.0, 0.0], + recording_type="remote", + meeting_id="meeting", + diarization_speaker_id="SPEAKER_0", + speech_duration_seconds=30.0, + segment_count=4, + created_from="user_confirmed", + channel="system", + diarization_run_id=run_id, + ) + return profile + + def test_list_profiles_reports_only_boolean_availability(self): + self._seed_playable_person() + + result = self._run(simple_recorder.list_person_profiles, []) + + self.assertEqual(result.exit_code, 0) + profile = json.loads(result.output)["person_profiles"][0] + self.assertIs(profile["sample_available"], True) + private_keys = { + "meeting_id", "channel", "diarization_speaker_id", "recording_path", + "prototypes", "embedding", "embedding_mean", + } + self.assertTrue(private_keys.isdisjoint(profile)) + + def test_list_profiles_treats_structurally_invalid_sidecars_as_unplayable(self): + self._seed_playable_person() + sidecar_path = self.output_dir / "meeting_speakers.json" + + malformed_sidecars = [ + { + "meeting_id": "meeting", + "diarization_run": {"run_id": read_speakers_sidecar( + self.output_dir, "meeting", + )["diarization_run"]["run_id"]}, + "channels": [], + }, + { + "meeting_id": "meeting", + "diarization_run": {"run_id": read_speakers_sidecar( + self.output_dir, "meeting", + )["diarization_run"]["run_id"]}, + "channels": { + "system": { + "recording_type": "remote", + "clusters": { + "SPEAKER_0": { + "speech_duration_seconds": 30.0, + "segment_count": 4, + "segments": [{"start": 0.25, "end": 1.75}], + }, + }, + }, + }, + }, + ] + for sidecar in malformed_sidecars: + with self.subTest(sidecar=sidecar): + sidecar_path.write_text(json.dumps(sidecar)) + + result = self._run(simple_recorder.list_person_profiles, []) + + self.assertEqual(result.exit_code, 0) + profile = json.loads(result.output)["person_profiles"][0] + self.assertIs(profile["sample_available"], False) + + def test_get_person_sample_audio_returns_valid_wav_base64(self): + profile = self._seed_playable_person() + + result = self._run(simple_recorder.get_person_sample_audio, [profile["person_id"]]) + + self.assertEqual(result.exit_code, 0) + payload = json.loads(result.output) + self.assertIs(payload["success"], True) + audio = base64.b64decode(payload["audio_base64"]) + self.assertEqual(audio[:4], b"RIFF") + self.assertEqual(audio[8:12], b"WAVE") + + def test_missing_person_returns_fixed_failure_without_provenance(self): + result = self._run(simple_recorder.get_person_sample_audio, ["missing"]) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(json.loads(result.output), { + "success": False, + "error": "voice sample unavailable", + }) + + def test_unplayable_person_returns_fixed_failure_without_provenance(self): + profile = self.config.create_person_profile("No recording") + + result = self._run(simple_recorder.get_person_sample_audio, [profile["person_id"]]) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(json.loads(result.output), { + "success": False, + "error": "voice sample unavailable", + }) + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_suggest_speakers_cli.py b/tests/test_suggest_speakers_cli.py index aebaa8b9..cb3f8a03 100644 --- a/tests/test_suggest_speakers_cli.py +++ b/tests/test_suggest_speakers_cli.py @@ -41,6 +41,19 @@ def _run(self, args, tmp, cfg=None): result = CliRunner().invoke(simple_recorder.suggest_speakers, args) return result + def test_missing_sidecar_is_an_empty_successful_result(self): + with tempfile.TemporaryDirectory() as tmp: + result = self._run(["missing"], tmp) + + self.assertEqual(result.exit_code, 0) + self.assertEqual(_last_json(result.output), { + "success": True, + "meeting_id": "missing", + "recording_available": False, + "minimum_speaker_count": 0, + "channels": {}, + }) + def test_includes_duration_segment_count_and_first_timestamp(self): with tempfile.TemporaryDirectory() as tmp: output_dir = Path(tmp) / "output" diff --git a/tests/test_transcriber_diarisation.py b/tests/test_transcriber_diarisation.py index 113d148f..cbca2fc2 100644 --- a/tests/test_transcriber_diarisation.py +++ b/tests/test_transcriber_diarisation.py @@ -36,6 +36,7 @@ _assign_asr_segments_to_diar_segments, _clamp_overlapping_diar_segments, _cluster_channel_labels, + _cluster_channel_label_plan, _diarised_split_timeout, _format_timestamp, _merge_close_diar_segments, @@ -1277,6 +1278,55 @@ def test_dominance_ratio_just_under_threshold_still_clusters(self): ] self.assertIsNotNone(_cluster_channel_labels(segments, "You")) + def test_sustained_minorities_stay_distinct_while_short_blip_folds_to_dominant(self): + # A long channel with 95.2% dominant speech can still contain two + # speakers with enough speech to deserve their own transcript labels. + segments = [ + {"start": 0.0, "end": 3487.4, "speaker": "SPEAKER_0"}, + {"start": 3487.4, "end": 3598.5, "speaker": "SPEAKER_1"}, + {"start": 3598.5, "end": 3659.7, "speaker": "SPEAKER_2"}, + {"start": 3659.7, "end": 3662.3, "speaker": "SPEAKER_3"}, + ] + expected_labels = { + "SPEAKER_0": "You", + "SPEAKER_1": "__diar__You__SPEAKER_1", + "SPEAKER_2": "__diar__You__SPEAKER_2", + "SPEAKER_3": "You", + } + + labels, eligible_speaker_ids = _cluster_channel_label_plan(segments, "You") + + self.assertEqual(labels, expected_labels) + self.assertEqual(eligible_speaker_ids, {"SPEAKER_0", "SPEAKER_1", "SPEAKER_2"}) + self.assertEqual(_cluster_channel_labels(segments, "You"), expected_labels) + + def test_dominant_channel_with_11_84_second_minority_remains_collapsed(self): + segments = [ + {"start": 0.0, "end": 180.0, "speaker": "SPEAKER_0"}, + {"start": 180.0, "end": 191.84, "speaker": "SPEAKER_1"}, + ] + + labels, _eligible_speaker_ids = _cluster_channel_label_plan(segments, "Others") + + self.assertIsNone(labels) + self.assertIsNone(_cluster_channel_labels(segments, "Others")) + + def test_fragmented_minority_above_duration_floor_remains_collapsed(self): + segments = [{"start": 0.0, "end": 300.0, "speaker": "SPEAKER_0"}] + segments.extend( + { + "start": 300.0 + index * 0.6, + "end": 300.6 + index * 0.6, + "speaker": "SPEAKER_1", + } + for index in range(30) + ) + + labels, eligible_speaker_ids = _cluster_channel_label_plan(segments, "You") + + self.assertIsNone(labels) + self.assertEqual(eligible_speaker_ids, set()) + class ResolveSpeakerPlaceholdersTests(unittest.TestCase): def test_legacy_labels_are_untouched(self): @@ -1596,6 +1646,77 @@ def test_self_match_relabels_and_demotes_previous_you(self): self.assertEqual(result["SPEAKER_1"], "You") self.assertEqual(result["SPEAKER_0"], "__diar__You__SPEAKER_0") + def test_self_match_reanchors_sustained_minority_without_promoting_folded_blip(self): + # SPEAKER_3 is a short folded blip with the closest owner embedding. + # It must be excluded from matching, so SPEAKER_1 becomes "You" and + # the blip stays attached to SPEAKER_0's replacement placeholder, + # even though the blip appears first in the label mapping. + cluster_labels = { + "SPEAKER_3": "You", + "SPEAKER_0": "You", + "SPEAKER_1": "__diar__You__SPEAKER_1", + "SPEAKER_2": "__diar__You__SPEAKER_2", + } + speaker_embeddings = { + "SPEAKER_3": [0.0, 1.0], + "SPEAKER_0": [1.0, 0.0], + "SPEAKER_1": [0.1, 0.995], + "SPEAKER_2": [-1.0, 0.0], + } + voiceprints = [ + {"name": "ignored", "centroid": [0.0, 1.0], "embeddings": [], "is_self": True}, + ] + with patch("src.config.get_config") as mock_get_config: + mock_get_config.return_value.get_voiceprints.return_value = voiceprints + result = _apply_voiceprint_matches( + speaker_embeddings, + cluster_labels, + "You", + allow_self_match=True, + eligible_speaker_ids={"SPEAKER_0", "SPEAKER_1", "SPEAKER_2"}, + ) + + self.assertEqual(result["SPEAKER_1"], "You") + self.assertEqual(result["SPEAKER_0"], "__diar__You__SPEAKER_0") + self.assertEqual(result["SPEAKER_2"], "__diar__You__SPEAKER_2") + self.assertEqual(result["SPEAKER_3"], "__diar__You__SPEAKER_0") + + def test_self_match_on_dominant_keeps_folded_blip_folded(self): + # A sustained minority keeps the channel split, while SPEAKER_2 is + # a short blip folded into SPEAKER_0. A self match already on that + # dominant cluster must leave both of their labels untouched. + segments = [ + {"start": 0.0, "end": 300.0, "speaker": "SPEAKER_0"}, + {"start": 300.0, "end": 320.0, "speaker": "SPEAKER_1"}, + {"start": 320.0, "end": 322.0, "speaker": "SPEAKER_2"}, + ] + cluster_labels, eligible_speaker_ids = _cluster_channel_label_plan(segments, "You") + expected_labels = { + "SPEAKER_0": "You", + "SPEAKER_1": "__diar__You__SPEAKER_1", + "SPEAKER_2": "You", + } + speaker_embeddings = { + "SPEAKER_0": [0.0, 1.0], + "SPEAKER_1": [1.0, 0.0], + "SPEAKER_2": [0.0, 1.0], + } + voiceprints = [ + {"name": "ignored", "centroid": [0.0, 1.0], "embeddings": [], "is_self": True}, + ] + with patch("src.config.get_config") as mock_get_config: + mock_get_config.return_value.get_voiceprints.return_value = voiceprints + result = _apply_voiceprint_matches( + speaker_embeddings, + cluster_labels, + "You", + allow_self_match=True, + eligible_speaker_ids=eligible_speaker_ids, + ) + + self.assertEqual(cluster_labels, expected_labels) + self.assertEqual(result, expected_labels) + def test_self_match_ignored_when_not_allowed(self): # System-audio channel (allow_self_match=False): matching is skipped # entirely — config isn't even loaded, since there's nothing left From a5a565e9f7720c929451b2807c3b7c1bf7652ac4 Mon Sep 17 00:00:00 2001 From: Optic00 Date: Fri, 7 Aug 2026 05:17:41 +0200 Subject: [PATCH 2/2] test(speakers): isolate person sample extraction --- tests/test_person_sample_audio_cli.py | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/tests/test_person_sample_audio_cli.py b/tests/test_person_sample_audio_cli.py index 793165a4..9bbf381e 100644 --- a/tests/test_person_sample_audio_cli.py +++ b/tests/test_person_sample_audio_cli.py @@ -255,7 +255,18 @@ def test_list_profiles_treats_structurally_invalid_sidecars_as_unplayable(self): def test_get_person_sample_audio_returns_valid_wav_base64(self): profile = self._seed_playable_person() - result = self._run(simple_recorder.get_person_sample_audio, [profile["person_id"]]) + def fake_extract(_recording_path, _channel, _segments, output_path): + _write_wav(Path(output_path), seconds=0.25) + return True + + with mock.patch( + "src.speaker_suggestions.extract_speaker_sample_audio", + side_effect=fake_extract, + ) as extract: + result = self._run( + simple_recorder.get_person_sample_audio, + [profile["person_id"]], + ) self.assertEqual(result.exit_code, 0) payload = json.loads(result.output) @@ -263,6 +274,7 @@ def test_get_person_sample_audio_returns_valid_wav_base64(self): audio = base64.b64decode(payload["audio_base64"]) self.assertEqual(audio[:4], b"RIFF") self.assertEqual(audio[8:12], b"WAVE") + extract.assert_called_once() def test_missing_person_returns_fixed_failure_without_provenance(self): result = self._run(simple_recorder.get_person_sample_audio, ["missing"])