Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { useCallback } from "react";

import * as main from "../../../../../../store/tinybase/main";
import { id } from "../../../../../../utils";
import { TranscriptContainer } from "./shared";

export function TranscriptEditor({ sessionId }: { sessionId: string }) {
Expand All @@ -27,10 +28,37 @@ export function TranscriptEditor({ sessionId }: { sessionId: string }) {
checkpoints.addCheckpoint("delete_word");
}, [store, indexes, checkpoints]);

const handleAssignSpeaker = useCallback((wordIds: string[], humanId: string) => {
if (!store || !checkpoints) {
return;
}

wordIds.forEach((wordId) => {
const word = store.getRow("words", wordId);
if (!word || typeof word.transcript_id !== "string") {
return;
}

const hintId = id();
store.setRow("speaker_hints", hintId, {
transcript_id: word.transcript_id,
word_id: wordId,
type: "user_speaker_assignment",
value: JSON.stringify({ human_id: humanId }),
created_at: new Date().toISOString(),
});
});

checkpoints.addCheckpoint("assign_speaker");
}, [store, checkpoints]);

return (
<TranscriptContainer
sessionId={sessionId}
operations={{ onDeleteWord: handleDeleteWord }}
operations={{
onDeleteWord: handleDeleteWord,
onAssignSpeaker: handleAssignSpeaker,
}}
/>
);
}
Original file line number Diff line number Diff line change
@@ -1,16 +1,18 @@
import { useCallback, useMemo, useRef, useState } from "react";

import { cn } from "@hypr/utils";
import { useListener } from "../../../../../../contexts/listener";
import * as main from "../../../../../../store/tinybase/main";
import { TranscriptEditor } from "./editor";
import { TranscriptViewer } from "./viewer";

export function Transcript({ sessionId }: { sessionId: string }) {
const inactive = useListener((state) => state.status === "inactive");
const [isEditing, setIsEditing] = useState(false);

return (
<div className="relative h-full flex flex-col">
<EditingControls isEditing={isEditing} setIsEditing={setIsEditing} />
{inactive && <EditingControls isEditing={isEditing} setIsEditing={setIsEditing} />}

<div className="flex-1 overflow-hidden">
{isEditing
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,19 +18,16 @@ import {
SegmentWord,
} from "../../../../../../../utils/segment";
import { convertStorageHintsToRuntime } from "../../../../../../../utils/speaker-hints";
import { Operations } from "./operations";
import { SegmentHeader } from "./segment-header";
import { SelectionMenu } from "./selection-menu";

type WordOperations = {
onDeleteWord?: (wordId: string) => void;
};

export function TranscriptContainer({
sessionId,
operations,
}: {
sessionId: string;
operations?: WordOperations;
operations?: Operations;
}) {
const transcriptIds = main.UI.useSliceRowIds(
main.INDEXES.transcriptBySession,
Expand Down Expand Up @@ -115,7 +112,7 @@ function TranscriptSeparator() {
])}
>
<div className="flex-1 border-t border-neutral-200/40" />
<span>Restarted</span>
<span>~ ~ ~ ~ ~ ~ ~ ~ ~</span>
<div className="flex-1 border-t border-neutral-200/40" />
</div>
);
Expand All @@ -133,7 +130,7 @@ function RenderTranscript(
transcriptId: string;
partialWords: PartialWord[];
partialHints: RuntimeSpeakerHint[];
operations?: WordOperations;
operations?: Operations;
},
) {
const finalWords = useFinalWords(transcriptId);
Expand Down Expand Up @@ -182,7 +179,7 @@ export function SegmentRenderer(
editable: boolean;
segment: Segment;
offsetMs: number;
operations?: WordOperations;
operations?: Operations;
},
) {
const { time, seek, start, audioExists } = useAudioPlayer();
Expand All @@ -197,7 +194,7 @@ export function SegmentRenderer(

return (
<section>
<SegmentHeader segment={segment} />
<SegmentHeader segment={segment} operations={operations} />

<div
className={cn([
Expand Down Expand Up @@ -243,7 +240,7 @@ function WordSpan({
word: SegmentWord;
highlightState: "current" | "buffer" | "none";
audioExists: boolean;
operations?: WordOperations;
operations?: Operations;
onSeekAndPlay: () => void;
}) {
const mode = operations && Object.keys(operations).length > 0 ? "editor" : "viewer";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type Operations = {
onDeleteWord?: (wordId: string) => void;
onAssignSpeaker?: (wordIds: string[], humanId: string) => void;
};
Original file line number Diff line number Diff line change
@@ -1,10 +1,21 @@
import chroma from "chroma-js";
import { useCallback, useMemo } from "react";

import {
ContextMenu,
ContextMenuContent,
ContextMenuItem,
ContextMenuSub,
ContextMenuSubContent,
ContextMenuSubTrigger,
ContextMenuTrigger,
} from "@hypr/ui/components/ui/context-menu";
import { cn } from "@hypr/utils";
import type { Segment } from "../../../../../../../utils/segment";
import * as main from "../../../../../../../store/tinybase/main";
import { ChannelProfile, type Segment } from "../../../../../../../utils/segment";
import { Operations } from "./operations";

export function SegmentHeader({ segment }: { segment: Segment }) {
export function SegmentHeader({ segment, operations }: { segment: Segment; operations?: Operations }) {
const formatTimestamp = useCallback((ms: number): string => {
const totalSeconds = Math.floor(ms / 1000);
const hours = Math.floor(totalSeconds / 3600);
Expand All @@ -30,9 +41,24 @@ export function SegmentHeader({ segment }: { segment: Segment }) {
return `${from} - ${to}`;
}, [segment.words.length, formatTimestamp]);

const colors = useSegmentColors(segment.key);
const color = useSegmentColor(segment.key);
const label = useSpeakerLabel(segment.key);
const humans = main.UI.useRowIds("humans", main.STORE_ID) ?? [];
const store = main.UI.useStore(main.STORE_ID);

return (
const mode = operations && Object.keys(operations).length > 0 ? "editor" : "viewer";
const wordIds = segment.words.filter((w) => w.id).map((w) => w.id!);

const handleAssignSpeaker = useCallback(
(humanId: string) => {
if (wordIds.length > 0 && operations?.onAssignSpeaker) {
operations.onAssignSpeaker(wordIds, humanId);
}
},
[wordIds, operations],
);

const headerContent = (
<p
className={cn([
"sticky top-0 z-20",
Expand All @@ -41,15 +67,45 @@ export function SegmentHeader({ segment }: { segment: Segment }) {
"border-b border-neutral-200",
"text-xs font-light",
"flex items-center justify-between",
mode === "editor" && "cursor-pointer hover:bg-neutral-50",
])}
>
<span style={{ color: colors.color }}>{colors.label}</span>
<span style={{ color }}>{label}</span>
<span className="font-mono text-neutral-500">{timestamp}</span>
</p>
);

if (mode === "editor" && wordIds.length > 0) {
return (
<ContextMenu>
<ContextMenuTrigger asChild>
{headerContent}
</ContextMenuTrigger>
<ContextMenuContent>
<ContextMenuSub>
<ContextMenuSubTrigger>Assign Speaker</ContextMenuSubTrigger>
<ContextMenuSubContent>
{humans.map((humanId) => {
const human = store?.getRow("humans", humanId);
const name = human?.name || humanId;
return (
<ContextMenuItem key={humanId} onClick={() => handleAssignSpeaker(humanId)}>
{name}
</ContextMenuItem>
);
})}
{humans.length === 0 && <ContextMenuItem disabled>No speakers available</ContextMenuItem>}
</ContextMenuSubContent>
</ContextMenuSub>
</ContextMenuContent>
</ContextMenu>
);
}

return headerContent;
}

function useSegmentColors(key: Segment["key"]) {
function useSegmentColor(key: Segment["key"]) {
return useMemo(() => {
const speakerIndex = key.speaker_index ?? 0;

Expand All @@ -64,9 +120,29 @@ function useSegmentColors(key: Segment["key"]) {
const light = 0.55;
const chromaVal = 0.15;

return {
color: chroma.oklch(light, chromaVal, hue).hex(),
label: key.speaker_index !== undefined ? `Speaker ${key.speaker_index + 1}` : `Speaker ${key.channel}`,
};
return chroma.oklch(light, chromaVal, hue).hex();
}, [key]);
}

function useSpeakerLabel(key: Segment["key"]) {
const store = main.UI.useStore(main.STORE_ID);

return useMemo(() => {
if (key.speaker_human_id && store) {
const human = store.getRow("humans", key.speaker_human_id);
if (human?.name) {
return human.name as string;
}
}

const channelLabel = key.channel === ChannelProfile.DirectMic
? "A"
: key.channel === ChannelProfile.RemoteParty
? "B"
: "C";

return key.speaker_index !== undefined
? `Speaker ${key.speaker_index + 1}`
: `Speaker ${channelLabel}`;
}, [key, store]);
}
110 changes: 110 additions & 0 deletions apps/desktop/src/devtool/seed/data/curated.json
Original file line number Diff line number Diff line change
Expand Up @@ -2671,6 +2671,116 @@
}
]
}
},
{
"title": "Quick Check-in",
"raw_md": "Quick conversation between colleagues.",
"enhanced_md": "Quick conversation between colleagues.",
"folder": null,
"event": null,
"participants": [
"Sarah Chen",
"Michael Rodriguez"
],
"tags": [],
"transcript": {
"segments": [
{
"channel": 0,
"start_ms": 0,
"end_ms": 2000,
"text": " How is the project going?",
"words": [
{
"text": " How",
"start_ms": 0,
"end_ms": 400
},
{
"text": " is",
"start_ms": 400,
"end_ms": 600
},
{
"text": " the",
"start_ms": 600,
"end_ms": 800
},
{
"text": " project",
"start_ms": 800,
"end_ms": 1400
},
{
"text": " going",
"start_ms": 1400,
"end_ms": 2000
}
]
},
{
"channel": 1,
"start_ms": 4100,
"end_ms": 6100,
"text": " It's going really well, thanks!",
"words": [
{
"text": " It's",
"start_ms": 4100,
"end_ms": 4500
},
{
"text": " going",
"start_ms": 4500,
"end_ms": 4900
},
{
"text": " really",
"start_ms": 4900,
"end_ms": 5300
},
{
"text": " well",
"start_ms": 5300,
"end_ms": 5700
},
{
"text": " thanks",
"start_ms": 5700,
"end_ms": 6100
}
]
},
{
"channel": 0,
"start_ms": 8200,
"end_ms": 9800,
"text": " That's great to hear!",
"words": [
{
"text": " That's",
"start_ms": 8200,
"end_ms": 8600
},
{
"text": " great",
"start_ms": 8600,
"end_ms": 9000
},
{
"text": " to",
"start_ms": 9000,
"end_ms": 9200
},
{
"text": " hear",
"start_ms": 9200,
"end_ms": 9800
}
]
}
]
}
}
],
"chat_groups": [
Expand Down
Loading
Loading