Skip to content

Commit a45e2ce

Browse files
Enable transcript speaker reassignment
Add clickable speaker labels with all-matching and segment-only assignment modes.
1 parent 3d3ee83 commit a45e2ce

10 files changed

Lines changed: 586 additions & 29 deletions

File tree

apps/desktop/src/session/components/note-input/transcript/renderer/speaker-assign.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
buildCreateSpeakerParticipantOption,
55
buildSpeakerParticipantGroups,
66
getAssignmentAnchorWordId,
7+
getAssignmentWordIds,
78
type SpeakerParticipantOption,
89
} from "./speaker-assign";
910

@@ -123,3 +124,46 @@ describe("getAssignmentAnchorWordId", () => {
123124
expect(getAssignmentAnchorWordId(segment)).toBe("word-2");
124125
});
125126
});
127+
128+
describe("getAssignmentWordIds", () => {
129+
it("returns every persisted word id in the segment", () => {
130+
const segment = {
131+
id: "segment-1",
132+
key: {
133+
channel: "DirectMic",
134+
speaker_index: 1,
135+
speaker_human_id: null,
136+
},
137+
start_ms: 0,
138+
end_ms: 300,
139+
text: "hello there",
140+
words: [
141+
{
142+
id: "word-1",
143+
text: "hello",
144+
start_ms: 0,
145+
end_ms: 100,
146+
channel: "DirectMic",
147+
is_final: true,
148+
},
149+
{
150+
text: " ",
151+
start_ms: 100,
152+
end_ms: 120,
153+
channel: "DirectMic",
154+
is_final: true,
155+
},
156+
{
157+
id: "word-2",
158+
text: "there",
159+
start_ms: 120,
160+
end_ms: 300,
161+
channel: "DirectMic",
162+
is_final: true,
163+
},
164+
],
165+
} as Segment;
166+
167+
expect(getAssignmentWordIds(segment)).toEqual(["word-1", "word-2"]);
168+
});
169+
});

apps/desktop/src/session/components/note-input/transcript/renderer/speaker-assign.tsx

Lines changed: 60 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ import * as main from "~/store/tinybase/store/main";
1212
import type { Segment } from "~/stt/live-segment";
1313
import { upsertSpeakerAssignment } from "~/stt/utils";
1414

15+
type AssignmentMode = "all" | "segment";
16+
1517
export function SpeakerAssignPopover({
1618
segment,
1719
transcriptId,
@@ -28,8 +30,8 @@ export function SpeakerAssignPopover({
2830
onAssigned?: (humanId: string) => void;
2931
}) {
3032
const [open, setOpen] = useState(false);
33+
const [assignmentMode, setAssignmentMode] = useState<AssignmentMode>("all");
3134
const store = main.UI.useStore(main.STORE_ID);
32-
const isSelf = segment.key.channel === "DirectMic";
3335

3436
const sessionId = main.UI.useCell(
3537
"transcripts",
@@ -49,21 +51,17 @@ export function SpeakerAssignPopover({
4951
segment.key,
5052
humanId,
5153
anchorWordId,
54+
{
55+
mode: assignmentMode,
56+
wordIds: getAssignmentWordIds(segment),
57+
},
5258
);
5359
onAssigned?.(humanId);
5460
setOpen(false);
5561
},
56-
[onAssigned, store, transcriptId, segment.key, segment.words],
62+
[assignmentMode, onAssigned, store, transcriptId, segment],
5763
);
5864

59-
if (isSelf) {
60-
return (
61-
<span className={className} style={{ color }}>
62-
{label}
63-
</span>
64-
);
65-
}
66-
6765
return (
6866
<Popover open={open} onOpenChange={setOpen}>
6967
<PopoverTrigger asChild>
@@ -80,12 +78,55 @@ export function SpeakerAssignPopover({
8078
</button>
8179
</PopoverTrigger>
8280
<PopoverContent variant="app" align="start" className="w-64">
81+
<AssignmentModePicker
82+
mode={assignmentMode}
83+
onChange={setAssignmentMode}
84+
/>
8385
<ParticipantList sessionId={sessionId} onSelect={handleAssign} />
8486
</PopoverContent>
8587
</Popover>
8688
);
8789
}
8890

91+
function AssignmentModePicker({
92+
mode,
93+
onChange,
94+
}: {
95+
mode: AssignmentMode;
96+
onChange: (mode: AssignmentMode) => void;
97+
}) {
98+
const options: Array<{ value: AssignmentMode; label: string }> = [
99+
{ value: "all", label: "All matching" },
100+
{ value: "segment", label: "This segment" },
101+
];
102+
103+
return (
104+
<div className="border-border border-b p-2">
105+
<div className="bg-muted grid h-8 grid-cols-2 rounded-md p-0.5">
106+
{options.map((option) => {
107+
const selected = mode === option.value;
108+
109+
return (
110+
<button
111+
key={option.value}
112+
type="button"
113+
className={cn([
114+
"rounded-sm px-2 text-xs transition-colors",
115+
selected
116+
? "bg-background text-foreground shadow-xs"
117+
: "text-muted-foreground hover:text-foreground",
118+
])}
119+
onClick={() => onChange(option.value)}
120+
>
121+
{option.label}
122+
</button>
123+
);
124+
})}
125+
</div>
126+
</div>
127+
);
128+
}
129+
89130
export function getAssignmentAnchorWordId(
90131
segment: Segment,
91132
): string | undefined {
@@ -95,6 +136,15 @@ export function getAssignmentAnchorWordId(
95136
return typeof word?.id === "string" ? word.id : undefined;
96137
}
97138

139+
export function getAssignmentWordIds(segment: Segment): string[] {
140+
return segment.words
141+
.map((word) => word.id)
142+
.filter(
143+
(wordId): wordId is string =>
144+
typeof wordId === "string" && wordId.length > 0,
145+
);
146+
}
147+
98148
export type SpeakerParticipantOption = {
99149
id: string;
100150
name: string;

apps/desktop/src/stt/render-transcript.test.ts

Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,47 @@ function createStore(participantIds = ["self", "remote"]): FakeStore {
8888
},
8989
]),
9090
},
91+
segmentOnly: {
92+
session_id: "session-1",
93+
started_at: 2_000,
94+
words: JSON.stringify([
95+
{
96+
id: "segment-word-1",
97+
text: " hello",
98+
start_ms: 0,
99+
end_ms: 100,
100+
channel: 1,
101+
},
102+
{
103+
id: "segment-word-2",
104+
text: " there",
105+
start_ms: 100,
106+
end_ms: 200,
107+
channel: 1,
108+
},
109+
]),
110+
speaker_hints: JSON.stringify([
111+
{
112+
word_id: "segment-word-1",
113+
type: "provider_speaker_index",
114+
value: JSON.stringify({ channel: 1, speaker_index: 2 }),
115+
},
116+
{
117+
word_id: "segment-word-2",
118+
type: "provider_speaker_index",
119+
value: JSON.stringify({ channel: 1, speaker_index: 2 }),
120+
},
121+
{
122+
word_id: "segment-word-1",
123+
type: "user_speaker_assignment",
124+
value: JSON.stringify({
125+
human_id: "remote",
126+
scope: "segment",
127+
word_ids: ["segment-word-1", "segment-word-2"],
128+
}),
129+
},
130+
]),
131+
},
91132
} as const;
92133

93134
const humans = {
@@ -201,6 +242,23 @@ describe("buildRenderTranscriptRequestFromStore", () => {
201242
]);
202243
});
203244

245+
it("turns segment speaker assignments into word-scoped render assignments", () => {
246+
const request = buildRenderTranscriptRequestFromStore(
247+
createStore() as never,
248+
["segmentOnly"],
249+
);
250+
251+
expect(request?.transcripts[0]?.assignments).toEqual([
252+
{
253+
human_id: "remote",
254+
scope: {
255+
kind: "words",
256+
word_ids: ["segment-word-1", "segment-word-2"],
257+
},
258+
},
259+
]);
260+
});
261+
204262
it("collects assigned speaker human ids from transcript rows", () => {
205263
expect(
206264
collectAssignedHumanIdsFromTranscriptRows([

apps/desktop/src/stt/render-transcript.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,12 +122,17 @@ export function getRenderTranscriptRequestKey(
122122
for (const assignment of transcript.assignments) {
123123
writeValue(assignment.human_id);
124124
writeValue(assignment.scope.kind);
125-
writeValue(assignment.scope.channel);
125+
writeValue(
126+
"channel" in assignment.scope ? assignment.scope.channel : null,
127+
);
126128
writeValue(
127129
"speaker_index" in assignment.scope
128130
? assignment.scope.speaker_index
129131
: null,
130132
);
133+
writeValue(
134+
"word_ids" in assignment.scope ? assignment.scope.word_ids : null,
135+
);
131136
}
132137
}
133138

@@ -334,6 +339,25 @@ function normalizeSpeakerHint(
334339
typeof (value as { human_id?: unknown }).human_id === "string"
335340
) {
336341
const humanId = (value as { human_id: string }).human_id;
342+
if (
343+
(value as { scope?: unknown }).scope === "segment" &&
344+
Array.isArray((value as { word_ids?: unknown }).word_ids)
345+
) {
346+
const wordIds = (value as { word_ids: unknown[] }).word_ids.filter(
347+
(wordId): wordId is string =>
348+
typeof wordId === "string" && wordId.length > 0,
349+
);
350+
if (wordIds.length > 0) {
351+
return {
352+
human_id: humanId,
353+
scope: {
354+
kind: "words",
355+
word_ids: wordIds,
356+
},
357+
};
358+
}
359+
}
360+
337361
return word.speaker_index == null
338362
? {
339363
human_id: humanId,

0 commit comments

Comments
 (0)