summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-23 19:11:57 +0000
committersoryu <soryu@soryu.co>2025-12-23 19:11:57 +0000
commitf5222a7ae5ade5589436778cb01fc0abe625b3c3 (patch)
tree6e9739517d371179e6018412cba011b3f38868ef
parent3c0adec8e3a9dd3bc34251e87e0fb5314793426d (diff)
downloadsoryu-f5222a7ae5ade5589436778cb01fc0abe625b3c3.tar.gz
soryu-f5222a7ae5ade5589436778cb01fc0abe625b3c3.zip
Add editable file sections and a drag&drop feature
-rw-r--r--makima/frontend/src/components/files/BodyRenderer.tsx289
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx61
-rw-r--r--makima/frontend/src/components/files/FileList.tsx14
-rw-r--r--makima/frontend/src/routes/files.tsx65
-rw-r--r--makima/src/server/handlers/listen.rs124
5 files changed, 466 insertions, 87 deletions
diff --git a/makima/frontend/src/components/files/BodyRenderer.tsx b/makima/frontend/src/components/files/BodyRenderer.tsx
index 9d008e2..06b2b75 100644
--- a/makima/frontend/src/components/files/BodyRenderer.tsx
+++ b/makima/frontend/src/components/files/BodyRenderer.tsx
@@ -1,11 +1,18 @@
+import { useState, useRef, useEffect } from "react";
import type { BodyElement } from "../../lib/api";
import { ChartRenderer } from "../charts/ChartRenderer";
interface BodyRendererProps {
elements: BodyElement[];
+ isEditing?: boolean;
+ onUpdate?: (index: number, element: BodyElement) => void;
+ onReorder?: (fromIndex: number, toIndex: number) => void;
}
-export function BodyRenderer({ elements }: BodyRendererProps) {
+export function BodyRenderer({ elements, isEditing = false, onUpdate, onReorder }: BodyRendererProps) {
+ const [draggedIndex, setDraggedIndex] = useState<number | null>(null);
+ const [dragOverIndex, setDragOverIndex] = useState<number | null>(null);
+
if (elements.length === 0) {
return (
<div className="text-[#555] font-mono text-sm italic">
@@ -14,21 +21,123 @@ export function BodyRenderer({ elements }: BodyRendererProps) {
);
}
+ const handleDragStart = (index: number) => (e: React.DragEvent) => {
+ setDraggedIndex(index);
+ e.dataTransfer.effectAllowed = "move";
+ e.dataTransfer.setData("text/plain", index.toString());
+ };
+
+ const handleDragOver = (index: number) => (e: React.DragEvent) => {
+ e.preventDefault();
+ e.dataTransfer.dropEffect = "move";
+ if (draggedIndex !== null && draggedIndex !== index) {
+ setDragOverIndex(index);
+ }
+ };
+
+ const handleDragLeave = () => {
+ setDragOverIndex(null);
+ };
+
+ const handleDrop = (toIndex: number) => (e: React.DragEvent) => {
+ e.preventDefault();
+ const fromIndex = draggedIndex;
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+
+ if (fromIndex !== null && fromIndex !== toIndex && onReorder) {
+ onReorder(fromIndex, toIndex);
+ }
+ };
+
+ const handleDragEnd = () => {
+ setDraggedIndex(null);
+ setDragOverIndex(null);
+ };
+
return (
- <div className="space-y-4">
+ <div className="space-y-1">
{elements.map((element, index) => (
- <BodyElementRenderer key={index} element={element} />
+ <div
+ key={index}
+ className={`group flex items-start gap-2 py-1 transition-all ${
+ draggedIndex === index ? "opacity-50" : ""
+ } ${
+ dragOverIndex === index
+ ? "border-t-2 border-[#75aafc] -mt-[2px] pt-[calc(0.25rem+2px)]"
+ : ""
+ }`}
+ onDragOver={handleDragOver(index)}
+ onDragLeave={handleDragLeave}
+ onDrop={handleDrop(index)}
+ >
+ {/* Drag handle - only show in edit mode */}
+ {isEditing && onReorder && (
+ <div
+ draggable
+ onDragStart={handleDragStart(index)}
+ onDragEnd={handleDragEnd}
+ className="flex-shrink-0 w-5 h-6 flex items-center justify-center cursor-grab active:cursor-grabbing opacity-0 group-hover:opacity-100 transition-opacity text-[#555] hover:text-[#75aafc]"
+ title="Drag to reorder"
+ >
+ <svg
+ width="12"
+ height="12"
+ viewBox="0 0 12 12"
+ fill="currentColor"
+ >
+ <circle cx="3" cy="2" r="1.5" />
+ <circle cx="9" cy="2" r="1.5" />
+ <circle cx="3" cy="6" r="1.5" />
+ <circle cx="9" cy="6" r="1.5" />
+ <circle cx="3" cy="10" r="1.5" />
+ <circle cx="9" cy="10" r="1.5" />
+ </svg>
+ </div>
+ )}
+ <div className="flex-1">
+ <BodyElementRenderer
+ element={element}
+ onUpdate={isEditing && onUpdate ? (el) => onUpdate(index, el) : undefined}
+ />
+ </div>
+ </div>
))}
</div>
);
}
-function BodyElementRenderer({ element }: { element: BodyElement }) {
+function BodyElementRenderer({
+ element,
+ onUpdate,
+}: {
+ element: BodyElement;
+ onUpdate?: (element: BodyElement) => void;
+}) {
switch (element.type) {
case "heading":
- return <HeadingElement level={element.level} text={element.text} />;
+ return (
+ <HeadingElement
+ level={element.level}
+ text={element.text}
+ onUpdate={
+ onUpdate
+ ? (text) => onUpdate({ ...element, text })
+ : undefined
+ }
+ />
+ );
case "paragraph":
- return <ParagraphElement text={element.text} />;
+ return (
+ <ParagraphElement
+ text={element.text}
+ onUpdate={
+ onUpdate
+ ? (text) => onUpdate({ ...element, text })
+ : undefined
+ }
+ />
+ );
case "chart":
return (
<ChartElement
@@ -51,29 +160,157 @@ function BodyElementRenderer({ element }: { element: BodyElement }) {
}
}
-function HeadingElement({ level, text }: { level: number; text: string }) {
- const className = "font-mono text-[#9bc3ff]";
-
- switch (level) {
- case 1:
- return <h1 className={`${className} text-2xl font-bold`}>{text}</h1>;
- case 2:
- return <h2 className={`${className} text-xl font-bold`}>{text}</h2>;
- case 3:
- return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>;
- case 4:
- return <h4 className={`${className} text-base font-semibold`}>{text}</h4>;
- case 5:
- return <h5 className={`${className} text-sm font-semibold`}>{text}</h5>;
- case 6:
- return <h6 className={`${className} text-xs font-semibold`}>{text}</h6>;
- default:
- return <h3 className={`${className} text-lg font-semibold`}>{text}</h3>;
+function HeadingElement({
+ level,
+ text,
+ onUpdate,
+}: {
+ level: number;
+ text: string;
+ onUpdate?: (text: string) => void;
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editText, setEditText] = useState(text);
+ const inputRef = useRef<HTMLInputElement>(null);
+
+ useEffect(() => {
+ setEditText(text);
+ }, [text]);
+
+ useEffect(() => {
+ if (isEditing && inputRef.current) {
+ inputRef.current.focus();
+ inputRef.current.select();
+ }
+ }, [isEditing]);
+
+ const handleSave = () => {
+ if (onUpdate && editText.trim() !== text) {
+ onUpdate(editText.trim());
+ }
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Enter") {
+ handleSave();
+ } else if (e.key === "Escape") {
+ setEditText(text);
+ setIsEditing(false);
+ }
+ };
+
+ const baseClassName = "font-mono text-[#9bc3ff]";
+ const sizeClasses: Record<number, string> = {
+ 1: "text-2xl font-bold",
+ 2: "text-xl font-bold",
+ 3: "text-lg font-semibold",
+ 4: "text-base font-semibold",
+ 5: "text-sm font-semibold",
+ 6: "text-xs font-semibold",
+ };
+ const sizeClass = sizeClasses[level] || sizeClasses[3];
+
+ if (isEditing && onUpdate) {
+ return (
+ <input
+ ref={inputRef}
+ type="text"
+ value={editText}
+ onChange={(e) => setEditText(e.target.value)}
+ onBlur={handleSave}
+ onKeyDown={handleKeyDown}
+ className={`${baseClassName} ${sizeClass} w-full bg-transparent border-b border-[#3f6fb3] outline-none`}
+ />
+ );
}
+
+ const HeadingTag = `h${level}` as "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
+ return (
+ <HeadingTag
+ className={`${baseClassName} ${sizeClass} ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
+ onClick={() => onUpdate && setIsEditing(true)}
+ >
+ {text}
+ </HeadingTag>
+ );
}
-function ParagraphElement({ text }: { text: string }) {
- return <p className="font-mono text-sm text-white/80 leading-relaxed">{text}</p>;
+function ParagraphElement({
+ text,
+ onUpdate,
+}: {
+ text: string;
+ onUpdate?: (text: string) => void;
+}) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [editText, setEditText] = useState(text);
+ const textareaRef = useRef<HTMLTextAreaElement>(null);
+
+ useEffect(() => {
+ setEditText(text);
+ }, [text]);
+
+ useEffect(() => {
+ if (isEditing && textareaRef.current) {
+ textareaRef.current.focus();
+ // Auto-resize textarea
+ textareaRef.current.style.height = "auto";
+ textareaRef.current.style.height = textareaRef.current.scrollHeight + "px";
+ }
+ }, [isEditing]);
+
+ const handleSave = () => {
+ if (onUpdate && editText.trim() !== text) {
+ onUpdate(editText.trim());
+ }
+ setIsEditing(false);
+ };
+
+ const handleKeyDown = (e: React.KeyboardEvent) => {
+ if (e.key === "Escape") {
+ setEditText(text);
+ setIsEditing(false);
+ }
+ // Ctrl/Cmd + Enter to save
+ if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
+ handleSave();
+ }
+ };
+
+ const handleInput = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ setEditText(e.target.value);
+ // Auto-resize
+ e.target.style.height = "auto";
+ e.target.style.height = e.target.scrollHeight + "px";
+ };
+
+ if (isEditing && onUpdate) {
+ return (
+ <div className="relative">
+ <textarea
+ ref={textareaRef}
+ value={editText}
+ onChange={handleInput}
+ onBlur={handleSave}
+ onKeyDown={handleKeyDown}
+ className="font-mono text-sm text-white/80 leading-relaxed w-full bg-transparent border border-[#3f6fb3] outline-none p-2 resize-none min-h-[60px]"
+ />
+ <div className="text-[10px] text-[#555] font-mono mt-1">
+ Ctrl+Enter to save, Esc to cancel
+ </div>
+ </div>
+ );
+ }
+
+ return (
+ <p
+ className={`font-mono text-sm text-white/80 leading-relaxed ${onUpdate ? "cursor-text hover:bg-[rgba(117,170,252,0.05)] px-1 -mx-1 rounded" : ""}`}
+ onClick={() => onUpdate && setIsEditing(true)}
+ >
+ {text}
+ </p>
+ );
}
function ChartElement({
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
index ffc67dd..2bf4c03 100644
--- a/makima/frontend/src/components/files/FileDetail.tsx
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -8,6 +8,8 @@ interface FileDetailProps {
onBack: () => void;
onSave: (id: string, name: string, description: string) => void;
onDelete: (id: string) => void;
+ onBodyElementUpdate?: (index: number, element: import("../../lib/api").BodyElement) => void;
+ onBodyReorder?: (fromIndex: number, toIndex: number) => void;
}
export function FileDetail({
@@ -16,6 +18,8 @@ export function FileDetail({
onBack,
onSave,
onDelete,
+ onBodyElementUpdate,
+ onBodyReorder,
}: FileDetailProps) {
const [isEditing, setIsEditing] = useState(false);
const [name, setName] = useState(file.name);
@@ -143,33 +147,34 @@ export function FileDetail({
<h3 className="font-mono text-xs text-[#75aafc] uppercase mb-3">
Content
</h3>
- <BodyRenderer elements={file.body} />
+ <BodyRenderer
+ elements={file.body}
+ isEditing={isEditing}
+ onUpdate={onBodyElementUpdate}
+ onReorder={onBodyReorder}
+ />
</div>
- {/* Collapsible Transcript Section */}
- <div className="border-t border-dashed border-[rgba(117,170,252,0.35)] pt-4">
- <button
- onClick={() => setTranscriptExpanded(!transcriptExpanded)}
- className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors uppercase w-full text-left"
- >
- <span
- className={`transition-transform ${
- transcriptExpanded ? "rotate-90" : ""
- }`}
+ {/* Collapsible Transcript Section - only show if there are entries */}
+ {file.transcript.length > 0 && (
+ <div className="border-t border-dashed border-[rgba(117,170,252,0.35)] pt-4">
+ <button
+ onClick={() => setTranscriptExpanded(!transcriptExpanded)}
+ className="flex items-center gap-2 font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors uppercase w-full text-left"
>
- &gt;
- </span>
- Transcript ({file.transcript.length} entries)
- </button>
+ <span
+ className={`transition-transform ${
+ transcriptExpanded ? "rotate-90" : ""
+ }`}
+ >
+ &gt;
+ </span>
+ Transcript ({file.transcript.length} entries)
+ </button>
- {transcriptExpanded && (
- <div className="mt-4 space-y-3 pl-4">
- {file.transcript.length === 0 ? (
- <div className="text-[#9bc3ff] text-sm font-mono opacity-60">
- No transcript entries.
- </div>
- ) : (
- file.transcript.map((entry) => (
+ {transcriptExpanded && (
+ <div className="mt-4 space-y-3 pl-4">
+ {file.transcript.map((entry) => (
<div key={entry.id} className="font-mono text-sm">
<div className="flex items-baseline gap-2 mb-1">
<span className="text-[#75aafc] text-xs">
@@ -183,11 +188,11 @@ export function FileDetail({
{entry.text}
</p>
</div>
- ))
- )}
- </div>
- )}
- </div>
+ ))}
+ </div>
+ )}
+ </div>
+ )}
</div>
</div>
);
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
index 7e1eea4..a859aa1 100644
--- a/makima/frontend/src/components/files/FileList.tsx
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -5,6 +5,7 @@ interface FileListProps {
loading: boolean;
onSelect: (id: string) => void;
onDelete: (id: string) => void;
+ onCreate: () => void;
}
function formatDuration(seconds: number | null): string {
@@ -30,6 +31,7 @@ export function FileList({
loading,
onSelect,
onDelete,
+ onCreate,
}: FileListProps) {
if (loading) {
return (
@@ -41,8 +43,16 @@ export function FileList({
return (
<div className="panel h-full flex flex-col">
- <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
- FILES//
+ <div className="flex items-center justify-between p-4 pb-2 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="font-mono text-xs text-[#9bc3ff] tracking-wide uppercase">
+ FILES//
+ </div>
+ <button
+ onClick={onCreate}
+ className="px-3 py-1 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] hover:bg-[rgba(117,170,252,0.05)] transition-colors uppercase"
+ >
+ + New
+ </button>
</div>
<div className="flex-1 overflow-y-auto">
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
index 00c334d..79544c5 100644
--- a/makima/frontend/src/routes/files.tsx
+++ b/makima/frontend/src/routes/files.tsx
@@ -10,9 +10,10 @@ import type { FileDetail as FileDetailType, BodyElement } from "../lib/api";
export default function FilesPage() {
const { id } = useParams<{ id: string }>();
const navigate = useNavigate();
- const { files, loading, error, fetchFile, editFile, removeFile } = useFiles();
+ const { files, loading, error, fetchFile, editFile, removeFile, saveFile } = useFiles();
const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
+ const [creating, setCreating] = useState(false);
// Load file detail when URL has an id
useEffect(() => {
@@ -72,6 +73,63 @@ export default function FilesPage() {
[fileDetail]
);
+ const handleBodyElementUpdate = useCallback(
+ async (index: number, element: BodyElement) => {
+ if (fileDetail && id) {
+ // Create new body array with updated element
+ const newBody = [...fileDetail.body];
+ newBody[index] = element;
+
+ // Update local state immediately for responsiveness
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+
+ // Save to backend
+ await editFile(id, { body: newBody });
+ }
+ },
+ [fileDetail, id, editFile]
+ );
+
+ const handleBodyReorder = useCallback(
+ async (fromIndex: number, toIndex: number) => {
+ if (fileDetail && id) {
+ // Create new body array with reordered elements
+ const newBody = [...fileDetail.body];
+ const [movedElement] = newBody.splice(fromIndex, 1);
+ newBody.splice(toIndex, 0, movedElement);
+
+ // Update local state immediately for responsiveness
+ setFileDetail({
+ ...fileDetail,
+ body: newBody,
+ });
+
+ // Save to backend
+ await editFile(id, { body: newBody });
+ }
+ },
+ [fileDetail, id, editFile]
+ );
+
+ const handleCreate = useCallback(async () => {
+ if (creating) return;
+ setCreating(true);
+ try {
+ const newFile = await saveFile({
+ name: `Untitled ${new Date().toLocaleDateString()}`,
+ transcript: [],
+ });
+ if (newFile) {
+ navigate(`/files/${newFile.id}`);
+ }
+ } finally {
+ setCreating(false);
+ }
+ }, [creating, saveFile, navigate]);
+
return (
<div className="relative z-10 h-screen flex flex-col overflow-hidden">
<Masthead showTicker={false} showNav />
@@ -92,6 +150,8 @@ export default function FilesPage() {
onBack={handleBack}
onSave={handleSave}
onDelete={handleDelete}
+ onBodyElementUpdate={handleBodyElementUpdate}
+ onBodyReorder={handleBodyReorder}
/>
</div>
<div className="shrink-0">
@@ -105,9 +165,10 @@ export default function FilesPage() {
) : (
<FileList
files={files}
- loading={loading}
+ loading={loading || creating}
onSelect={handleSelectFile}
onDelete={handleDelete}
+ onCreate={handleCreate}
/>
)}
</main>
diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs
index 3055cb7..5fc5cea 100644
--- a/makima/src/server/handlers/listen.rs
+++ b/makima/src/server/handlers/listen.rs
@@ -512,12 +512,12 @@ fn decode_audio_chunk(data: &[u8], format: &StartMessage) -> Vec<f32> {
}
}
-/// Deduplicate transcript entries by removing entries with similar start times and text.
+/// Deduplicate transcript entries by removing entries with similar times and text.
///
-/// Entries are considered duplicates if:
-/// - Start times are within 0.5 seconds of each other
-/// - Speaker is the same
-/// - Text is identical or one is a substring of the other
+/// Entries are considered duplicates if any of these are true:
+/// - Start times are within 1.5 seconds AND text is similar (same, substring, or high overlap)
+/// - Time ranges overlap significantly AND text is similar
+/// - Text is identical regardless of timing
fn deduplicate_transcripts(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry> {
if entries.is_empty() {
return vec![];
@@ -530,49 +530,115 @@ fn deduplicate_transcripts(entries: &[TranscriptEntry]) -> Vec<TranscriptEntry>
let mut result: Vec<TranscriptEntry> = Vec::new();
for entry in sorted {
+ // Normalize text for comparison
+ let entry_text_normalized = normalize_text(&entry.text);
+
// Check if this entry is a duplicate of any existing entry
- let is_duplicate = result.iter().any(|existing| {
- // Check if start times are close (within 0.5 seconds)
- let time_close = (existing.start - entry.start).abs() < 0.5;
+ let duplicate_idx = result.iter().position(|existing| {
+ let existing_text_normalized = normalize_text(&existing.text);
// Check if same speaker
let same_speaker = existing.speaker == entry.speaker;
- // Check if text matches or one contains the other
- let text_match = existing.text == entry.text
- || existing.text.contains(&entry.text)
- || entry.text.contains(&existing.text);
-
- time_close && same_speaker && text_match
+ // Check if start times are identical or very close
+ let start_identical = (existing.start - entry.start).abs() < 0.1;
+ let start_close = (existing.start - entry.start).abs() < 1.5;
+
+ // Check if time ranges overlap
+ let time_overlap = existing.start < entry.end && entry.start < existing.end;
+
+ // Check various text similarity conditions
+ let text_identical = existing_text_normalized == entry_text_normalized;
+ let text_contained = existing_text_normalized.contains(&entry_text_normalized)
+ || entry_text_normalized.contains(&existing_text_normalized);
+ let text_similar = text_similarity(&existing_text_normalized, &entry_text_normalized) > 0.7;
+
+ // Duplicate conditions:
+ // 1. Same speaker + identical start time (different end times = same segment refined)
+ // 2. Same speaker + close start + similar text
+ // 3. Same speaker + overlapping time + similar text
+ // 4. Identical text (likely a re-transcription)
+ (same_speaker && start_identical)
+ || (same_speaker && start_close && (text_identical || text_contained || text_similar))
+ || (same_speaker && time_overlap && (text_identical || text_contained))
+ || text_identical
});
- if !is_duplicate {
- result.push(entry);
- } else {
- // If duplicate, check if the new entry has longer text and update
- for existing in &mut result {
- let time_close = (existing.start - entry.start).abs() < 0.5;
- let same_speaker = existing.speaker == entry.speaker;
-
- if time_close && same_speaker && entry.text.len() > existing.text.len() {
- // Keep the longer text version
- existing.text = entry.text.clone();
- existing.end = entry.end;
- break;
+ match duplicate_idx {
+ Some(idx) => {
+ // If the new entry has longer text, update the existing one
+ if entry.text.len() > result[idx].text.len() {
+ result[idx].text = entry.text.clone();
+ result[idx].end = result[idx].end.max(entry.end);
+ } else {
+ // Extend end time if needed
+ result[idx].end = result[idx].end.max(entry.end);
+ }
+ }
+ None => {
+ result.push(entry);
+ }
+ }
+ }
+
+ // Second pass: merge adjacent segments with same speaker and similar text
+ let mut merged: Vec<TranscriptEntry> = Vec::new();
+ for entry in result {
+ if let Some(last) = merged.last_mut() {
+ // Check if this should be merged with the previous entry
+ let same_speaker = last.speaker == entry.speaker;
+ let adjacent = (entry.start - last.end).abs() < 0.5;
+ let text_overlap = normalize_text(&last.text).contains(&normalize_text(&entry.text))
+ || normalize_text(&entry.text).contains(&normalize_text(&last.text));
+
+ if same_speaker && adjacent && text_overlap {
+ // Merge: keep longer text, extend time range
+ if entry.text.len() > last.text.len() {
+ last.text = entry.text;
}
+ last.end = last.end.max(entry.end);
+ continue;
}
}
+ merged.push(entry);
}
// Reassign IDs to be sequential
- for (i, entry) in result.iter_mut().enumerate() {
+ for (i, entry) in merged.iter_mut().enumerate() {
let parts: Vec<&str> = entry.id.split('-').collect();
if let Some(session_prefix) = parts.first() {
entry.id = format!("{}-{}", session_prefix, i + 1);
}
}
- result
+ merged
+}
+
+/// Normalize text for comparison by lowercasing and collapsing whitespace.
+fn normalize_text(text: &str) -> String {
+ text.to_lowercase()
+ .split_whitespace()
+ .collect::<Vec<_>>()
+ .join(" ")
+}
+
+/// Calculate text similarity as a ratio of shared words.
+fn text_similarity(a: &str, b: &str) -> f32 {
+ if a.is_empty() || b.is_empty() {
+ return 0.0;
+ }
+
+ let words_a: std::collections::HashSet<&str> = a.split_whitespace().collect();
+ let words_b: std::collections::HashSet<&str> = b.split_whitespace().collect();
+
+ let intersection = words_a.intersection(&words_b).count();
+ let union = words_a.union(&words_b).count();
+
+ if union == 0 {
+ 0.0
+ } else {
+ intersection as f32 / union as f32
+ }
}
/// Process audio using sliding window through STT and streaming diarization models.