feat: add WhisperX diarization and speaker transcript UI
- add WhisperX diarization support to the Whisper VM server - normalize speaker timestamp segments from Whisper responses - document Hugging Face/pyannote VM setup and health checks - show diarized speaker transcript blocks in record and transcript views - group consecutive segments from the same speaker - remove duplicate paragraph transcript display when diarized segments exist - let diarized transcript content expand without an inner scrollbar
This commit is contained in:
1 parent
3abb5e9281
commit
9517bad3dc
10 files changed
+674
-73
No files matched your search
@@ -4,6 +4,7 @@ import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { motion } from "framer-motion";
|
||||
import {
|
||||
Check,
|
||||
Clock,
|
||||
Loader2,
|
||||
Mic,
|
||||
Pause,
|
||||
@@ -14,11 +15,12 @@ import {
|
||||
Sparkles,
|
||||
Square,
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { transcribeAudio } from "@/services/audio";
|
||||
import { transcriptService } from "@/services/transcripts";
|
||||
import type { Transcript } from "@/services/types";
|
||||
import type { Transcript, TranscriptSegment } from "@/services/types";
|
||||
import { AudioPlayer } from "@/components/audio-player";
|
||||
import { SendTranscriptDialog } from "@/components/send-transcript-dialog";
|
||||
|
||||
@@ -483,12 +485,42 @@ function RecordPage() {
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(event) => setTranscript(event.target.value)}
|
||||
rows={16}
|
||||
className="mt-5 w-full resize-none rounded-xl border border-border bg-background/35 p-4 text-sm leading-relaxed outline-none transition focus:border-primary/60"
|
||||
/>
|
||||
{currentTranscript.timestamps.length > 0 ? (
|
||||
<div className="mt-5 space-y-3 rounded-xl border border-border bg-background/35 p-4">
|
||||
{groupTranscriptSegments(currentTranscript.timestamps).map((segment, index) => {
|
||||
const speaker = segment.speaker ?? "Speaker";
|
||||
return (
|
||||
<div
|
||||
key={`${segment.start}-${segment.end}-${index}`}
|
||||
className="rounded-lg border border-border bg-secondary/20 p-3 text-sm"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold ${speakerTone(
|
||||
speaker,
|
||||
)}`}
|
||||
>
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
{speaker}
|
||||
</span>
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-primary">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatStamp(segment.start)} - {formatStamp(segment.end)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">{segment.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<textarea
|
||||
value={transcript}
|
||||
onChange={(event) => setTranscript(event.target.value)}
|
||||
rows={16}
|
||||
className="mt-5 w-full resize-none rounded-xl border border-border bg-background/35 p-4 text-sm leading-relaxed outline-none transition focus:border-primary/60"
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
@@ -506,3 +538,43 @@ function RecordPage() {
|
||||
function formatTime(seconds: number) {
|
||||
return `${String(Math.floor(seconds / 60)).padStart(2, "0")}:${String(seconds % 60).padStart(2, "0")}`;
|
||||
}
|
||||
|
||||
function speakerTone(speaker: string) {
|
||||
const tones = [
|
||||
"border-primary/25 bg-primary/15 text-primary",
|
||||
"border-emerald-400/25 bg-emerald-400/10 text-emerald-300",
|
||||
"border-sky-400/25 bg-sky-400/10 text-sky-300",
|
||||
"border-amber-400/25 bg-amber-400/10 text-amber-300",
|
||||
"border-rose-400/25 bg-rose-400/10 text-rose-300",
|
||||
];
|
||||
const number = Number(speaker.match(/\d+/)?.[0] ?? 1);
|
||||
return tones[(Math.max(number, 1) - 1) % tones.length];
|
||||
}
|
||||
|
||||
function groupTranscriptSegments(segments: TranscriptSegment[]) {
|
||||
return segments.reduce<TranscriptSegment[]>((groups, segment) => {
|
||||
const previous = groups.at(-1);
|
||||
const speaker = segment.speaker ?? "";
|
||||
if (previous && (previous.speaker ?? "") === speaker) {
|
||||
previous.end = segment.end;
|
||||
previous.text = `${previous.text} ${segment.text}`.trim();
|
||||
return groups;
|
||||
}
|
||||
groups.push({ ...segment });
|
||||
return groups;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function formatStamp(seconds: number) {
|
||||
const safeSeconds = Number.isFinite(seconds) ? Math.max(seconds, 0) : 0;
|
||||
const totalSeconds = Math.floor(safeSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(
|
||||
secs,
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -1,11 +1,22 @@
|
||||
import { createFileRoute, Link, useNavigate } from "@tanstack/react-router";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { ArrowLeft, Copy, Download, Loader2, Save, Send, Sparkles, Trash2 } from "lucide-react";
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Copy,
|
||||
Download,
|
||||
Loader2,
|
||||
Save,
|
||||
Send,
|
||||
Sparkles,
|
||||
Trash2,
|
||||
UserRound,
|
||||
} from "lucide-react";
|
||||
import { toast } from "sonner";
|
||||
import { transcriptService } from "@/services/transcripts";
|
||||
import { useAuth } from "@/context/auth";
|
||||
import type { Transcript } from "@/services/types";
|
||||
import type { Transcript, TranscriptSegment } from "@/services/types";
|
||||
import { AudioPlayer } from "@/components/audio-player";
|
||||
import { ConfirmDialog } from "@/components/confirm-dialog";
|
||||
import { SendTranscriptDialog } from "@/components/send-transcript-dialog";
|
||||
@@ -104,7 +115,7 @@ function TranscriptDetailPage() {
|
||||
}
|
||||
|
||||
async function copy() {
|
||||
await navigator.clipboard.writeText(text);
|
||||
await navigator.clipboard.writeText(formatTranscriptForClipboard(transcript));
|
||||
toast.success("Transcript copied");
|
||||
}
|
||||
|
||||
@@ -277,34 +288,19 @@ function TranscriptDetailPage() {
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={18}
|
||||
readOnly={isReceived}
|
||||
className="w-full resize-none bg-transparent text-sm leading-relaxed outline-none read-only:text-muted-foreground"
|
||||
/>
|
||||
{transcript.timestamps.length > 0 ? (
|
||||
<SpeakerTranscript transcript={transcript} />
|
||||
) : (
|
||||
<textarea
|
||||
value={text}
|
||||
onChange={(event) => setText(event.target.value)}
|
||||
rows={18}
|
||||
readOnly={isReceived}
|
||||
className="w-full resize-none bg-transparent text-sm leading-relaxed outline-none read-only:text-muted-foreground"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{transcript.timestamps.length > 0 && (
|
||||
<div className="glass rounded-2xl p-6">
|
||||
<h2 className="font-display text-lg font-semibold">Timestamps</h2>
|
||||
<div className="mt-4 max-h-72 space-y-2 overflow-y-auto">
|
||||
{transcript.timestamps.map((segment, index) => (
|
||||
<div
|
||||
key={`${segment.start}-${index}`}
|
||||
className="grid gap-3 rounded-xl border border-border bg-secondary/20 p-3 text-sm md:grid-cols-[120px_1fr]"
|
||||
>
|
||||
<span className="font-mono text-xs text-primary">
|
||||
{formatStamp(segment.start)} - {formatStamp(segment.end)}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{segment.text}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showSend && (
|
||||
<SendTranscriptDialog sending={sending} onClose={() => setShowSend(false)} onSend={send} />
|
||||
)}
|
||||
@@ -322,8 +318,105 @@ function TranscriptDetailPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function formatStamp(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
return `${minutes}:${String(secs).padStart(2, "0")}`;
|
||||
function SpeakerTranscript({ transcript }: { transcript: Transcript }) {
|
||||
const hasSpeakers = transcript.timestamps.some((segment) => segment.speaker);
|
||||
const groupedSegments = groupTranscriptSegments(transcript.timestamps);
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||
<h2 className="font-display text-lg font-semibold">
|
||||
{hasSpeakers ? "Speaker transcript" : "Timestamps"}
|
||||
</h2>
|
||||
{hasSpeakers && (
|
||||
<span className="rounded-full border border-primary/20 bg-primary/10 px-3 py-1 text-xs font-medium text-primary">
|
||||
{speakerCount(transcript)} {speakerCount(transcript) === 1 ? "speaker" : "speakers"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 space-y-3">
|
||||
{groupedSegments.map((segment, index) => {
|
||||
const speaker = segment.speaker ?? "Speaker";
|
||||
return (
|
||||
<div
|
||||
key={`${segment.start}-${segment.end}-${index}`}
|
||||
className="rounded-xl border border-border bg-secondary/20 p-4 text-sm"
|
||||
>
|
||||
<div className="mb-2 flex flex-wrap items-center gap-2">
|
||||
{segment.speaker && (
|
||||
<span
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs font-semibold ${speakerTone(
|
||||
speaker,
|
||||
)}`}
|
||||
>
|
||||
<UserRound className="h-3.5 w-3.5" />
|
||||
{speaker}
|
||||
</span>
|
||||
)}
|
||||
<span className="inline-flex items-center gap-1.5 font-mono text-xs text-primary">
|
||||
<Clock className="h-3.5 w-3.5" />
|
||||
{formatStamp(segment.start)} - {formatStamp(segment.end)}
|
||||
</span>
|
||||
</div>
|
||||
<p className="leading-relaxed text-muted-foreground">{segment.text}</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function formatTranscriptForClipboard(transcript: Transcript | null) {
|
||||
if (!transcript?.timestamps.length) return transcript?.transcriptText ?? "";
|
||||
return groupTranscriptSegments(transcript.timestamps)
|
||||
.map((segment) => {
|
||||
const speaker = segment.speaker ? `${segment.speaker}\n` : "";
|
||||
return `${speaker}${formatStamp(segment.start)} - ${formatStamp(segment.end)}\n${segment.text}`;
|
||||
})
|
||||
.join("\n\n");
|
||||
}
|
||||
|
||||
function speakerCount(transcript: Transcript) {
|
||||
return new Set(transcript.timestamps.map((segment) => segment.speaker).filter(Boolean)).size;
|
||||
}
|
||||
|
||||
function groupTranscriptSegments(segments: TranscriptSegment[]) {
|
||||
return segments.reduce<TranscriptSegment[]>((groups, segment) => {
|
||||
const previous = groups.at(-1);
|
||||
const speaker = segment.speaker ?? "";
|
||||
if (previous && (previous.speaker ?? "") === speaker) {
|
||||
previous.end = segment.end;
|
||||
previous.text = `${previous.text} ${segment.text}`.trim();
|
||||
return groups;
|
||||
}
|
||||
groups.push({ ...segment });
|
||||
return groups;
|
||||
}, []);
|
||||
}
|
||||
|
||||
function speakerTone(speaker: string) {
|
||||
const tones = [
|
||||
"border-primary/25 bg-primary/15 text-primary",
|
||||
"border-emerald-400/25 bg-emerald-400/10 text-emerald-300",
|
||||
"border-sky-400/25 bg-sky-400/10 text-sky-300",
|
||||
"border-amber-400/25 bg-amber-400/10 text-amber-300",
|
||||
"border-rose-400/25 bg-rose-400/10 text-rose-300",
|
||||
];
|
||||
const number = Number(speaker.match(/\d+/)?.[0] ?? 1);
|
||||
return tones[(Math.max(number, 1) - 1) % tones.length];
|
||||
}
|
||||
|
||||
function formatStamp(seconds: number) {
|
||||
const safeSeconds = Number.isFinite(seconds) ? Math.max(seconds, 0) : 0;
|
||||
const totalSeconds = Math.floor(safeSeconds);
|
||||
const hours = Math.floor(totalSeconds / 3600);
|
||||
const minutes = Math.floor((totalSeconds % 3600) / 60);
|
||||
const secs = totalSeconds % 60;
|
||||
if (hours > 0) {
|
||||
return `${String(hours).padStart(2, "0")}:${String(minutes).padStart(2, "0")}:${String(
|
||||
secs,
|
||||
).padStart(2, "0")}`;
|
||||
}
|
||||
return `${String(minutes).padStart(2, "0")}:${String(secs).padStart(2, "0")}`;
|
||||
}
|
||||
@@ -10,6 +10,7 @@ export type User = {
|
||||
export type UserSummary = Pick<User, "id" | "fullName" | "username" | "email">;
|
||||
|
||||
export type TranscriptSegment = {
|
||||
speaker?: string;
|
||||
start: number;
|
||||
end: number;
|
||||
text: string;
|
||||
|
||||
+45
-1
@@ -1,3 +1,7 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, mkdirSync, readFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath, URL } from "node:url";
|
||||
import tailwindcss from "@tailwindcss/vite";
|
||||
import { tanstackRouter } from "@tanstack/router-plugin/vite";
|
||||
@@ -5,11 +9,49 @@ import viteReact from "@vitejs/plugin-react";
|
||||
import { defineConfig, loadEnv } from "vite";
|
||||
import tsConfigPaths from "vite-tsconfig-paths";
|
||||
|
||||
export default defineConfig(({ mode }) => {
|
||||
function getDevHttpsOptions() {
|
||||
const certDir = path.join(tmpdir(), "orphion-vite-https");
|
||||
const keyPath = path.join(certDir, "localhost-key.pem");
|
||||
const certPath = path.join(certDir, "localhost-cert.pem");
|
||||
|
||||
if (!existsSync(keyPath) || !existsSync(certPath)) {
|
||||
mkdirSync(certDir, { recursive: true });
|
||||
execFileSync(
|
||||
"openssl",
|
||||
[
|
||||
"req",
|
||||
"-x509",
|
||||
"-newkey",
|
||||
"rsa:2048",
|
||||
"-nodes",
|
||||
"-sha256",
|
||||
"-days",
|
||||
"365",
|
||||
"-subj",
|
||||
"/CN=localhost",
|
||||
"-addext",
|
||||
"subjectAltName=DNS:localhost,IP:127.0.0.1,IP:0.0.0.0",
|
||||
"-keyout",
|
||||
keyPath,
|
||||
"-out",
|
||||
certPath,
|
||||
],
|
||||
{ stdio: "ignore" },
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
key: readFileSync(keyPath),
|
||||
cert: readFileSync(certPath),
|
||||
};
|
||||
}
|
||||
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
const viteEnv = loadEnv(mode, process.cwd(), "");
|
||||
const serviceHost = viteEnv.VITE_ORPHION_SERVICE_HOST || "127.0.0.1";
|
||||
const apiPort = viteEnv.VITE_API_PORT || "4000";
|
||||
const apiProxyTarget = viteEnv.VITE_API_PROXY_TARGET || `http://${serviceHost}:${apiPort}`;
|
||||
const https = command === "serve" ? getDevHttpsOptions() : undefined;
|
||||
|
||||
return {
|
||||
plugins: [
|
||||
@@ -30,6 +72,7 @@ export default defineConfig(({ mode }) => {
|
||||
dedupe: ["react", "react-dom", "@tanstack/react-query", "@tanstack/query-core"],
|
||||
},
|
||||
server: {
|
||||
...(https ? { https } : {}),
|
||||
proxy: {
|
||||
"/api": {
|
||||
target: apiProxyTarget,
|
||||
@@ -37,6 +80,7 @@ export default defineConfig(({ mode }) => {
|
||||
},
|
||||
},
|
||||
},
|
||||
...(https ? { preview: { https } } : {}),
|
||||
build: {
|
||||
rollupOptions: {
|
||||
output: {
|
||||
|
||||
Reference in new issue
Block a user