Skip to content

Commit f3a2de5

Browse files
Carry speaker assignments into the live transcript engine
Naming a speaker mid-meeting only relabeled the main transcript view through a JavaScript overlay; the live engine kept emitting unlabeled segments, so the meeting float and plugin snapshot still showed "Speaker N", and a listener refresh started a fresh engine that knew nothing about the pick. The desktop config sync now follows the captured session's newest transcript and pushes its resolved identity assignments with the participant list. The engine folds them into segment building the same way the settled render does (hints first, participant channel defaults after), re-renders the retained window on an identity update so on-screen segments relabel without waiting for the next stream response, and seeds a refreshed listener with the same assignments. Assignment changes never trigger a listener refresh.
1 parent f371cc6 commit f3a2de5

12 files changed

Lines changed: 598 additions & 30 deletions

File tree

‎apps/desktop/src/services/event-listeners.test.tsx‎

Lines changed: 155 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,17 @@ vi.mock("~/store/zustand/listener/instance", () => ({
106106
},
107107
}));
108108

109+
function findLiveQueryHandlers(sqlFragment: string) {
110+
const call = liveQuerySubscribeMock.mock.calls.find(([sql]) =>
111+
String(sql).includes(sqlFragment),
112+
);
113+
expect(call).toBeDefined();
114+
return call![2] as {
115+
onData: (rows: unknown[]) => void;
116+
onError: (error: unknown) => void;
117+
};
118+
}
119+
109120
describe("EventListeners notification events", () => {
110121
beforeEach(() => {
111122
cancelAutoStopEndedNotification("session-1");
@@ -275,9 +286,9 @@ describe("EventListeners notification events", () => {
275286
render(<EventListeners />);
276287

277288
await vi.waitFor(() =>
278-
expect(liveQuerySubscribeMock).toHaveBeenCalledTimes(1),
289+
expect(liveQuerySubscribeMock).toHaveBeenCalledTimes(2),
279290
);
280-
const handlers = liveQuerySubscribeMock.mock.calls[0]?.[2];
291+
const handlers = findLiveQueryHandlers("session_participants");
281292
handlers.onData([
282293
{
283294
session_id: "session-1",
@@ -292,6 +303,148 @@ describe("EventListeners notification events", () => {
292303
languages: ["ko"],
293304
participant_human_ids: ["human-remote"],
294305
self_human_id: "human-self",
306+
speaker_assignments: [],
307+
});
308+
});
309+
310+
test("live capture config sync waits for the transcript snapshot before pushing", async () => {
311+
vi.useFakeTimers();
312+
useConfigValuesMock.mockReturnValue({
313+
ai_language: "ko",
314+
spoken_languages: ["ko"],
315+
current_stt_provider: "soniox",
316+
current_stt_model: "stt-v4",
317+
});
318+
// The transcript query answers later than the participant query here.
319+
liveQuerySubscribeMock.mockImplementation(
320+
async (sql, _params, handlers) => {
321+
if (!String(sql).includes("FROM transcripts")) {
322+
handlers.onData([]);
323+
}
324+
return async () => {};
325+
},
326+
);
327+
328+
render(<EventListeners />);
329+
330+
await vi.waitFor(() =>
331+
expect(liveQuerySubscribeMock).toHaveBeenCalledTimes(2),
332+
);
333+
findLiveQueryHandlers("session_participants").onData([
334+
{
335+
session_id: "session-1",
336+
owner_user_id: "human-self",
337+
human_id: "human-remote",
338+
},
339+
]);
340+
await vi.runOnlyPendingTimersAsync();
341+
342+
expect(updateCaptureConfigMock).not.toHaveBeenCalled();
343+
344+
findLiveQueryHandlers("FROM transcripts").onData([]);
345+
await vi.runOnlyPendingTimersAsync();
346+
347+
expect(updateCaptureConfigMock).toHaveBeenCalledTimes(1);
348+
expect(updateCaptureConfigMock).toHaveBeenCalledWith(
349+
expect.objectContaining({
350+
session_id: "session-1",
351+
speaker_assignments: [],
352+
}),
353+
);
354+
});
355+
356+
test("live capture config sync pushes the active transcript's speaker assignments", async () => {
357+
vi.useFakeTimers();
358+
useConfigValuesMock.mockReturnValue({
359+
ai_language: "en",
360+
spoken_languages: ["en"],
361+
current_stt_provider: "soniox",
362+
current_stt_model: "stt-v4",
363+
});
364+
365+
render(<EventListeners />);
366+
367+
await vi.waitFor(() =>
368+
expect(liveQuerySubscribeMock).toHaveBeenCalledTimes(2),
369+
);
370+
const transcriptCall = liveQuerySubscribeMock.mock.calls.find(([sql]) =>
371+
String(sql).includes("FROM transcripts"),
372+
);
373+
expect(transcriptCall?.[1]).toEqual(["session-1"]);
374+
375+
findLiveQueryHandlers("session_participants").onData([
376+
{
377+
session_id: "session-1",
378+
owner_user_id: "human-self",
379+
human_id: "human-artem",
380+
},
381+
{
382+
session_id: "session-1",
383+
owner_user_id: "human-self",
384+
human_id: "human-guest",
385+
},
386+
]);
387+
findLiveQueryHandlers("FROM transcripts").onData([
388+
{
389+
id: "transcript-1",
390+
started_at_ms: 1_000,
391+
words_json: JSON.stringify([
392+
{ id: "w1", text: " hello", start_ms: 0, end_ms: 100, channel: 1 },
393+
{ id: "w2", text: " there", start_ms: 100, end_ms: 200, channel: 1 },
394+
]),
395+
speaker_hints_json: JSON.stringify([
396+
{
397+
id: "w1:provider_speaker_index",
398+
word_id: "w1",
399+
type: "provider_speaker_index",
400+
value: JSON.stringify({ channel: 1, speaker_index: 0 }),
401+
},
402+
{
403+
id: "w1:user_speaker_assignment",
404+
word_id: "w1",
405+
type: "user_speaker_assignment",
406+
value: JSON.stringify({
407+
human_id: "human-artem",
408+
scope: "speaker",
409+
channel: 1,
410+
speaker_index: 0,
411+
}),
412+
},
413+
{
414+
id: "w2:user_speaker_assignment:segment",
415+
word_id: "w2",
416+
type: "user_speaker_assignment",
417+
value: JSON.stringify({
418+
human_id: "human-guest",
419+
scope: "segment",
420+
word_ids: ["w2"],
421+
}),
422+
},
423+
]),
424+
},
425+
]);
426+
await vi.runOnlyPendingTimersAsync();
427+
428+
expect(updateCaptureConfigMock).toHaveBeenCalledTimes(1);
429+
expect(updateCaptureConfigMock).toHaveBeenCalledWith({
430+
session_id: "session-1",
431+
languages: ["en"],
432+
participant_human_ids: ["human-artem", "human-guest"],
433+
self_human_id: "human-self",
434+
speaker_assignments: [
435+
{
436+
human_id: "human-artem",
437+
scope: {
438+
kind: "channel_speaker",
439+
channel: "RemoteParty",
440+
speaker_index: 0,
441+
},
442+
},
443+
{
444+
human_id: "human-guest",
445+
scope: { kind: "words", word_ids: ["w2"] },
446+
},
447+
],
295448
});
296449
});
297450

‎apps/desktop/src/services/event-listeners.tsx‎

Lines changed: 130 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { type UnlistenFn } from "@tauri-apps/api/event";
22

33
import { events as notificationEvents } from "@anlg/plugin-notification";
4+
import type {
5+
CaptureConfigUpdate,
6+
IdentityAssignment,
7+
} from "@anlg/plugin-transcription";
48
import {
59
commands as updaterCommands,
610
events as updaterEvents,
@@ -26,13 +30,21 @@ import {
2630
getLiveTranscriptionConfig,
2731
getTranscriptionLanguages,
2832
} from "~/stt/capabilities";
33+
import { buildRenderTranscriptRequestFromRows } from "~/stt/render-transcript";
2934

3035
type CaptureIdentitySqlRow = {
3136
session_id: string;
3237
owner_user_id: string;
3338
human_id: string | null;
3439
};
3540

41+
type LiveTranscriptIdentitySqlRow = {
42+
id: string;
43+
started_at_ms: number | string;
44+
words_json: string;
45+
speaker_hints_json: string;
46+
};
47+
3648
const CAPTURE_IDENTITY_SQL = `
3749
SELECT
3850
session.id AS session_id,
@@ -48,6 +60,21 @@ const CAPTURE_IDENTITY_SQL = `
4860
ORDER BY session.id, participant.human_id
4961
`;
5062

63+
// The capture writes into the session's newest transcript. Speaker hints for
64+
// live words are only materialized into speaker_hints_json by transcript
65+
// mutations (assignments, flushes), so this does not need the delta journal.
66+
const LIVE_TRANSCRIPT_IDENTITY_SQL = `
67+
SELECT
68+
transcript.id,
69+
transcript.started_at_ms,
70+
transcript.words_json,
71+
transcript.speaker_hints_json
72+
FROM transcripts AS transcript
73+
WHERE transcript.session_id = ? AND transcript.deleted_at IS NULL
74+
ORDER BY transcript.started_at_ms DESC, transcript.created_at DESC
75+
LIMIT 1
76+
`;
77+
5178
const LIVE_CAPTURE_CONFIG_DEBOUNCE_MS = 750;
5279

5380
async function shouldAutoStartNotificationSession(
@@ -138,12 +165,34 @@ function getSessionParticipantHumanIds(
138165
return participantHumanIds;
139166
}
140167

141-
function createCaptureConfigSignature(config: {
142-
session_id: string;
143-
languages: string[];
144-
participant_human_ids: string[];
145-
self_human_id: string | null;
146-
}) {
168+
function getLiveSpeakerAssignments(
169+
rows: LiveTranscriptIdentitySqlRow[],
170+
): IdentityAssignment[] {
171+
const row = rows[0];
172+
if (!row) {
173+
return [];
174+
}
175+
176+
const request = buildRenderTranscriptRequestFromRows([
177+
{
178+
started_at: Number(row.started_at_ms),
179+
words: parseJsonArray(row.words_json),
180+
speaker_hints: parseJsonArray(row.speaker_hints_json),
181+
},
182+
]);
183+
return request?.transcripts[0]?.assignments ?? [];
184+
}
185+
186+
function parseJsonArray<T>(value: string): T[] {
187+
try {
188+
const parsed = JSON.parse(value);
189+
return Array.isArray(parsed) ? (parsed as T[]) : [];
190+
} catch {
191+
return [];
192+
}
193+
}
194+
195+
function createCaptureConfigSignature(config: CaptureConfigUpdate) {
147196
return JSON.stringify(config);
148197
}
149198

@@ -205,8 +254,12 @@ function LiveCaptureConfigSyncReady({
205254
let lastSignature: string | null = null;
206255
let rows: CaptureIdentitySqlRow[] = [];
207256
let hasSnapshot = false;
257+
let transcriptRows: LiveTranscriptIdentitySqlRow[] = [];
258+
let hasTranscriptSnapshot = false;
259+
let transcriptSessionId: string | null = null;
208260
let cancelled = false;
209261
let unsubscribeDatabase: (() => Promise<void>) | null = null;
262+
let unsubscribeTranscript: (() => Promise<void>) | null = null;
210263

211264
const pushConfig = async () => {
212265
if (!hasSnapshot) {
@@ -218,6 +271,12 @@ function LiveCaptureConfigSyncReady({
218271
return;
219272
}
220273

274+
// An empty assignment list is not a no-op: the engine drops the names it
275+
// holds. Wait for the transcript query's first rows before pushing.
276+
if (transcriptSessionId === live.sessionId && !hasTranscriptSnapshot) {
277+
return;
278+
}
279+
221280
const languages = getLiveConfigLanguages(
222281
settingsValues.ai_language,
223282
settingsValues.spoken_languages,
@@ -233,14 +292,18 @@ function LiveCaptureConfigSyncReady({
233292
}
234293

235294
const session = rows.find((row) => row.session_id === live.sessionId);
236-
const nextConfig = {
295+
const nextConfig: CaptureConfigUpdate = {
237296
session_id: live.sessionId,
238297
languages: liveConfig.languages,
239298
participant_human_ids: getSessionParticipantHumanIds(
240299
rows,
241300
live.sessionId,
242301
),
243302
self_human_id: session?.owner_user_id || null,
303+
speaker_assignments:
304+
transcriptSessionId === live.sessionId
305+
? getLiveSpeakerAssignments(transcriptRows)
306+
: [],
244307
};
245308
const signature = createCaptureConfigSignature(nextConfig);
246309
if (signature === lastSignature) {
@@ -266,7 +329,65 @@ function LiveCaptureConfigSyncReady({
266329
}, LIVE_CAPTURE_CONFIG_DEBOUNCE_MS);
267330
};
268331

269-
const unsubscribeListener = listenerStore.subscribe(schedulePush);
332+
// Speaker hints live on the transcript row, so follow whichever session is
333+
// being captured instead of watching every transcript in the database.
334+
const syncTranscriptSubscription = () => {
335+
const live = listenerStore.getState().live;
336+
const sessionId = live.status === "active" ? live.sessionId : null;
337+
if (sessionId === transcriptSessionId) {
338+
return;
339+
}
340+
341+
transcriptSessionId = sessionId;
342+
transcriptRows = [];
343+
hasTranscriptSnapshot = false;
344+
void unsubscribeTranscript?.();
345+
unsubscribeTranscript = null;
346+
if (!sessionId) {
347+
return;
348+
}
349+
350+
void liveQueryClient
351+
.subscribe<LiveTranscriptIdentitySqlRow>(
352+
LIVE_TRANSCRIPT_IDENTITY_SQL,
353+
[sessionId],
354+
{
355+
onData: (nextRows) => {
356+
if (transcriptSessionId !== sessionId) {
357+
return;
358+
}
359+
transcriptRows = nextRows;
360+
hasTranscriptSnapshot = true;
361+
schedulePush();
362+
},
363+
onError: (error) => {
364+
console.error(
365+
"[listener] failed to read live transcript speakers",
366+
error,
367+
);
368+
},
369+
},
370+
)
371+
.then((unsubscribe) => {
372+
if (cancelled || transcriptSessionId !== sessionId) {
373+
void unsubscribe();
374+
} else {
375+
unsubscribeTranscript = unsubscribe;
376+
}
377+
})
378+
.catch((error) => {
379+
console.error(
380+
"[listener] failed to subscribe to live transcript speakers",
381+
error,
382+
);
383+
});
384+
};
385+
386+
const unsubscribeListener = listenerStore.subscribe(() => {
387+
syncTranscriptSubscription();
388+
schedulePush();
389+
});
390+
syncTranscriptSubscription();
270391
void liveQueryClient
271392
.subscribe<CaptureIdentitySqlRow>(CAPTURE_IDENTITY_SQL, [], {
272393
onData: (nextRows) => {
@@ -302,6 +423,7 @@ function LiveCaptureConfigSyncReady({
302423
}
303424
unsubscribeListener();
304425
void unsubscribeDatabase?.();
426+
void unsubscribeTranscript?.();
305427
};
306428
});
307429

0 commit comments

Comments
 (0)