summaryrefslogtreecommitdiff
path: root/makima
diff options
context:
space:
mode:
authorsoryu <soryu@soryu.co>2025-12-23 02:14:58 +0000
committersoryu <soryu@soryu.co>2025-12-23 14:47:18 +0000
commita32dc56d2e5447ef8988cb98b8686476cc94e70c (patch)
tree61307503c4af82103cea2360fe95d3ea324968d6 /makima
parent73649d135efccda7e446775db773e21b458de202 (diff)
downloadsoryu-a32dc56d2e5447ef8988cb98b8686476cc94e70c.tar.gz
soryu-a32dc56d2e5447ef8988cb98b8686476cc94e70c.zip
Add Postgres for persistence and File cabinet
Migrations are local only currently, and must be run manually by setting POSTGRES_CONNECTION_URI
Diffstat (limited to 'makima')
-rw-r--r--makima/Cargo.toml8
-rw-r--r--makima/frontend/src/components/NavStrip.tsx1
-rw-r--r--makima/frontend/src/components/files/FileDetail.tsx143
-rw-r--r--makima/frontend/src/components/files/FileList.tsx96
-rw-r--r--makima/frontend/src/components/listen/ControlPanel.tsx11
-rw-r--r--makima/frontend/src/hooks/useFiles.ts105
-rw-r--r--makima/frontend/src/lib/api.ts102
-rw-r--r--makima/frontend/src/main.tsx3
-rw-r--r--makima/frontend/src/routes/files.tsx95
-rw-r--r--makima/frontend/src/routes/listen.tsx8
-rw-r--r--makima/frontend/tsconfig.tsbuildinfo2
-rw-r--r--makima/migrations/20241222000000_create_files_table.sql31
-rwxr-xr-x[-rw-r--r--]makima/sh/download-models.sh0
-rwxr-xr-xmakima/sh/run-migrations.sh11
-rwxr-xr-xmakima/sh/setup-db.sh22
-rwxr-xr-xmakima/sh/start-postgres.sh32
-rw-r--r--makima/src/bin/server.rs26
-rw-r--r--makima/src/db/mod.rs15
-rw-r--r--makima/src/db/models.rs101
-rw-r--r--makima/src/db/repository.rs128
-rw-r--r--makima/src/lib.rs1
-rw-r--r--makima/src/server/handlers/files.rs230
-rw-r--r--makima/src/server/handlers/listen.rs82
-rw-r--r--makima/src/server/handlers/mod.rs1
-rw-r--r--makima/src/server/mod.rs9
-rw-r--r--makima/src/server/openapi.rs22
-rw-r--r--makima/src/server/state.rs14
27 files changed, 1277 insertions, 22 deletions
diff --git a/makima/Cargo.toml b/makima/Cargo.toml
index 3368a6e..35c5db8 100644
--- a/makima/Cargo.toml
+++ b/makima/Cargo.toml
@@ -27,12 +27,16 @@ futures = "0.3"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
bytes = "1.0"
-uuid = { version = "1.0", features = ["v4"] }
+uuid = { version = "1.0", features = ["v4", "serde"] }
# OpenAPI
-utoipa = { version = "5", features = ["axum_extras"] }
+utoipa = { version = "5", features = ["axum_extras", "uuid", "chrono"] }
utoipa-swagger-ui = { version = "9", features = ["axum"] }
# Error handling
thiserror = "2.0"
anyhow = "1.0"
+
+# Database
+sqlx = { version = "0.8", features = ["runtime-tokio", "postgres", "uuid", "chrono", "json"] }
+chrono = { version = "0.4", features = ["serde"] }
diff --git a/makima/frontend/src/components/NavStrip.tsx b/makima/frontend/src/components/NavStrip.tsx
index 875af5a..4e90d4d 100644
--- a/makima/frontend/src/components/NavStrip.tsx
+++ b/makima/frontend/src/components/NavStrip.tsx
@@ -9,6 +9,7 @@ interface NavLink {
const NAV_LINKS: NavLink[] = [
{ label: "Listen", href: "/listen" },
+ { label: "Files", href: "/files" },
{ label: "Mesh", href: "/mesh", disabled: true },
{ label: "Register", href: "/register", disabled: true },
{ label: "Login", href: "/login", disabled: true },
diff --git a/makima/frontend/src/components/files/FileDetail.tsx b/makima/frontend/src/components/files/FileDetail.tsx
new file mode 100644
index 0000000..643f35e
--- /dev/null
+++ b/makima/frontend/src/components/files/FileDetail.tsx
@@ -0,0 +1,143 @@
+import { useState } from "react";
+import type { FileDetail as FileDetailType } from "../../lib/api";
+
+interface FileDetailProps {
+ file: FileDetailType;
+ loading: boolean;
+ onBack: () => void;
+ onSave: (id: string, name: string, description: string) => void;
+ onDelete: (id: string) => void;
+}
+
+export function FileDetail({
+ file,
+ loading,
+ onBack,
+ onSave,
+ onDelete,
+}: FileDetailProps) {
+ const [isEditing, setIsEditing] = useState(false);
+ const [name, setName] = useState(file.name);
+ const [description, setDescription] = useState(file.description || "");
+
+ const handleSave = () => {
+ onSave(file.id, name, description);
+ setIsEditing(false);
+ };
+
+ const handleCancel = () => {
+ setName(file.name);
+ setDescription(file.description || "");
+ setIsEditing(false);
+ };
+
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ );
+ }
+
+ return (
+ <div className="panel h-full flex flex-col">
+ {/* Header */}
+ <div className="p-4 border-b border-dashed border-[rgba(117,170,252,0.35)]">
+ <div className="flex items-center justify-between mb-3">
+ <button
+ onClick={onBack}
+ className="font-mono text-xs text-[#75aafc] hover:text-[#9bc3ff] transition-colors"
+ >
+ &larr; Back to list
+ </button>
+ <div className="flex gap-2">
+ {isEditing ? (
+ <>
+ <button
+ onClick={handleCancel}
+ className="px-3 py-1.5 font-mono text-xs text-[#9bc3ff] border border-[rgba(117,170,252,0.25)] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Cancel
+ </button>
+ <button
+ onClick={handleSave}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0f3c78] border border-[#3f6fb3] hover:bg-[#153667] transition-colors uppercase"
+ >
+ Save
+ </button>
+ </>
+ ) : (
+ <>
+ <button
+ onClick={() => setIsEditing(true)}
+ className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase"
+ >
+ Edit
+ </button>
+ <button
+ onClick={() => onDelete(file.id)}
+ className="px-3 py-1.5 font-mono text-xs text-red-400 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </>
+ )}
+ </div>
+ </div>
+
+ {isEditing ? (
+ <div className="space-y-3">
+ <input
+ type="text"
+ value={name}
+ onChange={(e) => setName(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc]"
+ placeholder="File name"
+ />
+ <textarea
+ value={description}
+ onChange={(e) => setDescription(e.target.value)}
+ className="w-full px-3 py-2 bg-[#0d1b2d] border border-[#3f6fb3] text-[#dbe7ff] font-mono text-sm focus:outline-none focus:border-[#75aafc] resize-none"
+ rows={2}
+ placeholder="Description (optional)"
+ />
+ </div>
+ ) : (
+ <>
+ <h2 className="font-mono text-lg text-[#dbe7ff] mb-1">
+ {file.name}
+ </h2>
+ {file.description && (
+ <p className="font-mono text-sm text-[#9bc3ff]">
+ {file.description}
+ </p>
+ )}
+ </>
+ )}
+ </div>
+
+ {/* Transcript */}
+ <div className="flex-1 overflow-y-auto p-4 space-y-3">
+ {file.transcript.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No transcript entries.
+ </div>
+ ) : (
+ 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">
+ [{entry.start.toFixed(2)}s - {entry.end.toFixed(2)}s]
+ </span>
+ <span className="text-[#9bc3ff] text-xs font-bold">
+ {entry.speaker}
+ </span>
+ </div>
+ <p className="m-0 text-[#dbe7ff] leading-relaxed">{entry.text}</p>
+ </div>
+ ))
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/files/FileList.tsx b/makima/frontend/src/components/files/FileList.tsx
new file mode 100644
index 0000000..7e1eea4
--- /dev/null
+++ b/makima/frontend/src/components/files/FileList.tsx
@@ -0,0 +1,96 @@
+import type { FileSummary } from "../../lib/api";
+
+interface FileListProps {
+ files: FileSummary[];
+ loading: boolean;
+ onSelect: (id: string) => void;
+ onDelete: (id: string) => void;
+}
+
+function formatDuration(seconds: number | null): string {
+ if (seconds === null) return "-";
+ const mins = Math.floor(seconds / 60);
+ const secs = Math.floor(seconds % 60);
+ return `${mins}:${secs.toString().padStart(2, "0")}`;
+}
+
+function formatDate(dateStr: string): string {
+ const date = new Date(dateStr);
+ return date.toLocaleDateString("en-US", {
+ month: "short",
+ day: "numeric",
+ year: "numeric",
+ hour: "2-digit",
+ minute: "2-digit",
+ });
+}
+
+export function FileList({
+ files,
+ loading,
+ onSelect,
+ onDelete,
+}: FileListProps) {
+ if (loading) {
+ return (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading files...</div>
+ </div>
+ );
+ }
+
+ 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>
+
+ <div className="flex-1 overflow-y-auto">
+ {files.length === 0 ? (
+ <div className="text-center text-[#9bc3ff] text-sm font-mono opacity-60 py-8">
+ No saved files yet. Start recording to create one.
+ </div>
+ ) : (
+ <div className="divide-y divide-[rgba(117,170,252,0.15)]">
+ {files.map((file) => (
+ <div
+ key={file.id}
+ className="p-4 hover:bg-[rgba(117,170,252,0.05)] transition-colors"
+ >
+ <div className="flex items-start justify-between gap-4">
+ <button
+ onClick={() => onSelect(file.id)}
+ className="flex-1 text-left"
+ >
+ <h3 className="font-mono text-sm text-[#dbe7ff] mb-1">
+ {file.name}
+ </h3>
+ {file.description && (
+ <p className="font-mono text-xs text-[#9bc3ff] mb-2 line-clamp-2">
+ {file.description}
+ </p>
+ )}
+ <div className="flex gap-4 font-mono text-[10px] text-[#75aafc]">
+ <span>{file.transcriptCount} segments</span>
+ <span>{formatDuration(file.duration)}</span>
+ <span>{formatDate(file.createdAt)}</span>
+ </div>
+ </button>
+ <button
+ onClick={(e) => {
+ e.stopPropagation();
+ onDelete(file.id);
+ }}
+ className="px-2 py-1 font-mono text-[10px] text-red-400 hover:bg-red-400/10 border border-red-400/30 hover:border-red-400/50 transition-colors uppercase"
+ >
+ Delete
+ </button>
+ </div>
+ </div>
+ ))}
+ </div>
+ )}
+ </div>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/components/listen/ControlPanel.tsx b/makima/frontend/src/components/listen/ControlPanel.tsx
index 25dbefe..af2cd05 100644
--- a/makima/frontend/src/components/listen/ControlPanel.tsx
+++ b/makima/frontend/src/components/listen/ControlPanel.tsx
@@ -6,8 +6,9 @@ interface ControlPanelProps {
isConnected: boolean;
micStatus: MicrophoneStatus;
micVolume: number;
+ hasTranscripts: boolean;
onToggle: () => void;
- onReset: () => void;
+ onNew: () => void;
error?: string | null;
}
@@ -33,8 +34,9 @@ export function ControlPanel({
isConnected,
micStatus,
micVolume,
+ hasTranscripts,
onToggle,
- onReset,
+ onNew,
error,
}: ControlPanelProps) {
const statusText = getStatusText(isListening, micStatus);
@@ -125,10 +127,11 @@ export function ControlPanel({
{/* Buttons */}
<div className="flex gap-2">
<button
- onClick={onReset}
+ onClick={onNew}
className="px-3 py-1.5 font-mono text-xs text-[#dbe7ff] bg-[#0d1b2d] border border-[#0f3c78] hover:border-[#3f6fb3] transition-colors uppercase tracking-wide"
+ title={hasTranscripts ? "Save and start new session" : "Start new session"}
>
- Reset
+ New
</button>
<button
disabled
diff --git a/makima/frontend/src/hooks/useFiles.ts b/makima/frontend/src/hooks/useFiles.ts
new file mode 100644
index 0000000..aacbb6a
--- /dev/null
+++ b/makima/frontend/src/hooks/useFiles.ts
@@ -0,0 +1,105 @@
+import { useState, useCallback, useEffect } from "react";
+import {
+ listFiles,
+ getFile,
+ createFile,
+ updateFile,
+ deleteFile,
+ type FileSummary,
+ type FileDetail,
+ type CreateFileRequest,
+ type UpdateFileRequest,
+} from "../lib/api";
+
+export function useFiles() {
+ const [files, setFiles] = useState<FileSummary[]>([]);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState<string | null>(null);
+
+ const fetchFiles = useCallback(async () => {
+ setLoading(true);
+ setError(null);
+ try {
+ const response = await listFiles();
+ setFiles(response.files);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch files");
+ } finally {
+ setLoading(false);
+ }
+ }, []);
+
+ const fetchFile = useCallback(
+ async (id: string): Promise<FileDetail | null> => {
+ setError(null);
+ try {
+ return await getFile(id);
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to fetch file");
+ return null;
+ }
+ },
+ []
+ );
+
+ const saveFile = useCallback(
+ async (data: CreateFileRequest): Promise<FileDetail | null> => {
+ setError(null);
+ try {
+ const file = await createFile(data);
+ await fetchFiles(); // Refresh list
+ return file;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to save file");
+ return null;
+ }
+ },
+ [fetchFiles]
+ );
+
+ const editFile = useCallback(
+ async (id: string, data: UpdateFileRequest): Promise<FileDetail | null> => {
+ setError(null);
+ try {
+ const file = await updateFile(id, data);
+ await fetchFiles(); // Refresh list
+ return file;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to update file");
+ return null;
+ }
+ },
+ [fetchFiles]
+ );
+
+ const removeFile = useCallback(
+ async (id: string): Promise<boolean> => {
+ setError(null);
+ try {
+ await deleteFile(id);
+ await fetchFiles(); // Refresh list
+ return true;
+ } catch (e) {
+ setError(e instanceof Error ? e.message : "Failed to delete file");
+ return false;
+ }
+ },
+ [fetchFiles]
+ );
+
+ // Initial fetch
+ useEffect(() => {
+ fetchFiles();
+ }, [fetchFiles]);
+
+ return {
+ files,
+ loading,
+ error,
+ fetchFiles,
+ fetchFile,
+ saveFile,
+ editFile,
+ removeFile,
+ };
+}
diff --git a/makima/frontend/src/lib/api.ts b/makima/frontend/src/lib/api.ts
index a6f6c3e..ec596ce 100644
--- a/makima/frontend/src/lib/api.ts
+++ b/makima/frontend/src/lib/api.ts
@@ -38,3 +38,105 @@ export const LISTEN_ENDPOINT = `${WS_BASE}/api/v1/listen`;
export function getEnvironment(): Environment {
return env;
}
+
+// File API types
+export interface TranscriptEntry {
+ id: string;
+ speaker: string;
+ start: number;
+ end: number;
+ text: string;
+ isFinal: boolean;
+}
+
+export interface FileSummary {
+ id: string;
+ name: string;
+ description: string | null;
+ transcriptCount: number;
+ duration: number | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FileDetail {
+ id: string;
+ ownerId: string;
+ name: string;
+ description: string | null;
+ transcript: TranscriptEntry[];
+ location: string | null;
+ createdAt: string;
+ updatedAt: string;
+}
+
+export interface FileListResponse {
+ files: FileSummary[];
+ total: number;
+}
+
+export interface CreateFileRequest {
+ name?: string;
+ description?: string;
+ transcript: TranscriptEntry[];
+ location?: string;
+}
+
+export interface UpdateFileRequest {
+ name?: string;
+ description?: string;
+ transcript?: TranscriptEntry[];
+}
+
+// File API functions
+export async function listFiles(): Promise<FileListResponse> {
+ const res = await fetch(`${API_BASE}/api/v1/files`);
+ if (!res.ok) {
+ throw new Error(`Failed to list files: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function getFile(id: string): Promise<FileDetail> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}`);
+ if (!res.ok) {
+ throw new Error(`Failed to get file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function createFile(data: CreateFileRequest): Promise<FileDetail> {
+ const res = await fetch(`${API_BASE}/api/v1/files`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to create file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function updateFile(
+ id: string,
+ data: UpdateFileRequest
+): Promise<FileDetail> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ method: "PUT",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify(data),
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to update file: ${res.statusText}`);
+ }
+ return res.json();
+}
+
+export async function deleteFile(id: string): Promise<void> {
+ const res = await fetch(`${API_BASE}/api/v1/files/${id}`, {
+ method: "DELETE",
+ });
+ if (!res.ok) {
+ throw new Error(`Failed to delete file: ${res.statusText}`);
+ }
+}
diff --git a/makima/frontend/src/main.tsx b/makima/frontend/src/main.tsx
index fe5be21..874ab1a 100644
--- a/makima/frontend/src/main.tsx
+++ b/makima/frontend/src/main.tsx
@@ -5,6 +5,7 @@ import "./index.css";
import { GridOverlay } from "./components/GridOverlay";
import HomePage from "./routes/_index";
import ListenPage from "./routes/listen";
+import FilesPage from "./routes/files";
createRoot(document.getElementById("root")!).render(
<StrictMode>
@@ -13,6 +14,8 @@ createRoot(document.getElementById("root")!).render(
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/listen" element={<ListenPage />} />
+ <Route path="/files" element={<FilesPage />} />
+ <Route path="/files/:id" element={<FilesPage />} />
</Routes>
</BrowserRouter>
</StrictMode>
diff --git a/makima/frontend/src/routes/files.tsx b/makima/frontend/src/routes/files.tsx
new file mode 100644
index 0000000..86a24b8
--- /dev/null
+++ b/makima/frontend/src/routes/files.tsx
@@ -0,0 +1,95 @@
+import { useState, useCallback, useEffect } from "react";
+import { useParams, useNavigate } from "react-router";
+import { Masthead } from "../components/Masthead";
+import { FileList } from "../components/files/FileList";
+import { FileDetail } from "../components/files/FileDetail";
+import { useFiles } from "../hooks/useFiles";
+import type { FileDetail as FileDetailType } from "../lib/api";
+
+export default function FilesPage() {
+ const { id } = useParams<{ id: string }>();
+ const navigate = useNavigate();
+ const { files, loading, error, fetchFile, editFile, removeFile } = useFiles();
+ const [fileDetail, setFileDetail] = useState<FileDetailType | null>(null);
+ const [detailLoading, setDetailLoading] = useState(false);
+
+ // Load file detail when URL has an id
+ useEffect(() => {
+ if (id) {
+ setDetailLoading(true);
+ fetchFile(id).then((detail) => {
+ setFileDetail(detail);
+ setDetailLoading(false);
+ });
+ } else {
+ setFileDetail(null);
+ }
+ }, [id, fetchFile]);
+
+ const handleSelectFile = useCallback(
+ (fileId: string) => {
+ navigate(`/files/${fileId}`);
+ },
+ [navigate]
+ );
+
+ const handleBack = useCallback(() => {
+ navigate("/files");
+ }, [navigate]);
+
+ const handleDelete = useCallback(
+ async (fileId: string) => {
+ if (confirm("Are you sure you want to delete this file?")) {
+ const success = await removeFile(fileId);
+ if (success && id === fileId) {
+ navigate("/files");
+ }
+ }
+ },
+ [removeFile, id, navigate]
+ );
+
+ const handleSave = useCallback(
+ async (fileId: string, name: string, description: string) => {
+ await editFile(fileId, { name, description });
+ const detail = await fetchFile(fileId);
+ setFileDetail(detail);
+ },
+ [editFile, fetchFile]
+ );
+
+ return (
+ <div className="relative z-10 h-screen flex flex-col overflow-hidden">
+ <Masthead showTicker={false} showNav />
+
+ <main className="flex-1 p-4 md:p-6 min-h-0 overflow-hidden">
+ {error && (
+ <div className="mb-4 p-3 border border-red-400/50 bg-red-400/10 text-red-400 font-mono text-sm">
+ {error}
+ </div>
+ )}
+
+ {id && fileDetail ? (
+ <FileDetail
+ file={fileDetail}
+ loading={detailLoading}
+ onBack={handleBack}
+ onSave={handleSave}
+ onDelete={handleDelete}
+ />
+ ) : id && detailLoading ? (
+ <div className="panel h-full flex items-center justify-center">
+ <div className="font-mono text-[#9bc3ff] text-sm">Loading...</div>
+ </div>
+ ) : (
+ <FileList
+ files={files}
+ loading={loading}
+ onSelect={handleSelectFile}
+ onDelete={handleDelete}
+ />
+ )}
+ </main>
+ </div>
+ );
+}
diff --git a/makima/frontend/src/routes/listen.tsx b/makima/frontend/src/routes/listen.tsx
index 9ac0a94..aaba90c 100644
--- a/makima/frontend/src/routes/listen.tsx
+++ b/makima/frontend/src/routes/listen.tsx
@@ -112,10 +112,11 @@ export default function ListenPage() {
setIsListening(true);
}, [isListening, mic, ws]);
- const handleReset = useCallback(() => {
+ const handleNew = useCallback(() => {
+ // Stop current session - backend auto-saves transcript on disconnect
mic.stop();
if (ws.isConnected) {
- ws.stopSession("reset");
+ ws.stopSession("new_session");
}
ws.clearTranscripts();
ws.disconnect();
@@ -147,8 +148,9 @@ export default function ListenPage() {
isConnected={ws.isConnected}
micStatus={mic.status}
micVolume={mic.volume}
+ hasTranscripts={ws.transcripts.length > 0}
onToggle={handleToggle}
- onReset={handleReset}
+ onNew={handleNew}
error={error}
/>
</div>
diff --git a/makima/frontend/tsconfig.tsbuildinfo b/makima/frontend/tsconfig.tsbuildinfo
index 5d6a380..9bb4ba8 100644
--- a/makima/frontend/tsconfig.tsbuildinfo
+++ b/makima/frontend/tsconfig.tsbuildinfo
@@ -1 +1 @@
-{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
+{"root":["./src/main.tsx","./src/vite-env.d.ts","./src/components/gridoverlay.tsx","./src/components/logo.tsx","./src/components/masthead.tsx","./src/components/navstrip.tsx","./src/components/rewritelink.tsx","./src/components/files/filedetail.tsx","./src/components/files/filelist.tsx","./src/components/listen/controlpanel.tsx","./src/components/listen/speakerpanel.tsx","./src/components/listen/transcriptpanel.tsx","./src/hooks/usefiles.ts","./src/hooks/usemicrophone.ts","./src/hooks/usetextscramble.ts","./src/hooks/usewebsocket.ts","./src/lib/api.ts","./src/routes/_index.tsx","./src/routes/files.tsx","./src/routes/listen.tsx","./src/types/messages.ts"],"version":"5.9.3"} \ No newline at end of file
diff --git a/makima/migrations/20241222000000_create_files_table.sql b/makima/migrations/20241222000000_create_files_table.sql
new file mode 100644
index 0000000..cf6f76c
--- /dev/null
+++ b/makima/migrations/20241222000000_create_files_table.sql
@@ -0,0 +1,31 @@
+-- Create files table for storing transcription records
+CREATE TABLE IF NOT EXISTS files (
+ id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
+ owner_id UUID NOT NULL DEFAULT '00000000-0000-0000-0000-000000000002',
+ name VARCHAR(255) NOT NULL,
+ description TEXT,
+ transcript JSONB NOT NULL DEFAULT '[]'::jsonb,
+ location VARCHAR(512),
+ created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
+ updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
+);
+
+-- Create index on owner_id for efficient filtering
+CREATE INDEX idx_files_owner_id ON files(owner_id);
+
+-- Create index on created_at for sorting
+CREATE INDEX idx_files_created_at ON files(created_at DESC);
+
+-- Create trigger to auto-update updated_at
+CREATE OR REPLACE FUNCTION update_updated_at_column()
+RETURNS TRIGGER AS $$
+BEGIN
+ NEW.updated_at = NOW();
+ RETURN NEW;
+END;
+$$ language 'plpgsql';
+
+CREATE TRIGGER update_files_updated_at
+ BEFORE UPDATE ON files
+ FOR EACH ROW
+ EXECUTE FUNCTION update_updated_at_column();
diff --git a/makima/sh/download-models.sh b/makima/sh/download-models.sh
index 0381e15..0381e15 100644..100755
--- a/makima/sh/download-models.sh
+++ b/makima/sh/download-models.sh
diff --git a/makima/sh/run-migrations.sh b/makima/sh/run-migrations.sh
new file mode 100755
index 0000000..d34d6b1
--- /dev/null
+++ b/makima/sh/run-migrations.sh
@@ -0,0 +1,11 @@
+#!/bin/bash
+# Run sqlx migrations
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+MAKIMA_DIR="$(dirname "${SCRIPT_DIR}")"
+
+POSTGRES_CONNECTION_URI="${POSTGRES_CONNECTION_URI:-postgres://makima:makima_dev@localhost:5432/makima}"
+
+echo "Running migrations from ${MAKIMA_DIR}/migrations..."
+sqlx migrate run --source "${MAKIMA_DIR}/migrations" --database-url "${POSTGRES_CONNECTION_URI}"
+echo "Migrations complete!"
diff --git a/makima/sh/setup-db.sh b/makima/sh/setup-db.sh
new file mode 100755
index 0000000..95e35ac
--- /dev/null
+++ b/makima/sh/setup-db.sh
@@ -0,0 +1,22 @@
+#!/bin/bash
+# Combined database setup script
+
+SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
+
+echo "=== Setting up Makima Database ==="
+echo ""
+
+# Start PostgreSQL
+echo "Step 1: Starting PostgreSQL..."
+bash "${SCRIPT_DIR}/start-postgres.sh"
+echo ""
+
+# Wait a moment for full initialization
+sleep 2
+
+# Run migrations
+echo "Step 2: Running migrations..."
+bash "${SCRIPT_DIR}/run-migrations.sh"
+echo ""
+
+echo "=== Database setup complete! ==="
diff --git a/makima/sh/start-postgres.sh b/makima/sh/start-postgres.sh
new file mode 100755
index 0000000..203b178
--- /dev/null
+++ b/makima/sh/start-postgres.sh
@@ -0,0 +1,32 @@
+#!/bin/bash
+# Start PostgreSQL via Docker for local development
+
+CONTAINER_NAME="makima-postgres"
+POSTGRES_USER="makima"
+POSTGRES_PASSWORD="makima_dev"
+POSTGRES_DB="makima"
+POSTGRES_PORT="5432"
+
+# Check if container already exists
+if docker ps -a --format '{{.Names}}' | grep -q "^${CONTAINER_NAME}$"; then
+ echo "Container ${CONTAINER_NAME} exists. Starting..."
+ docker start ${CONTAINER_NAME}
+else
+ echo "Creating new PostgreSQL container..."
+ docker run -d \
+ --name ${CONTAINER_NAME} \
+ -e POSTGRES_USER=${POSTGRES_USER} \
+ -e POSTGRES_PASSWORD=${POSTGRES_PASSWORD} \
+ -e POSTGRES_DB=${POSTGRES_DB} \
+ -p ${POSTGRES_PORT}:5432 \
+ -v makima_postgres_data:/var/lib/postgresql/data \
+ postgres:16-alpine
+fi
+
+echo "Waiting for PostgreSQL to be ready..."
+until docker exec ${CONTAINER_NAME} pg_isready -U ${POSTGRES_USER} 2>/dev/null; do
+ sleep 1
+done
+
+echo "PostgreSQL is ready!"
+echo "Connection URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@localhost:${POSTGRES_PORT}/${POSTGRES_DB}"
diff --git a/makima/src/bin/server.rs b/makima/src/bin/server.rs
index 3ea3a67..bbc56fd 100644
--- a/makima/src/bin/server.rs
+++ b/makima/src/bin/server.rs
@@ -1,6 +1,6 @@
//! Makima Audio API Server binary.
//!
-//! This server provides WebSocket-based speech-to-text streaming.
+//! This server provides WebSocket-based speech-to-text streaming with optional persistence.
use std::sync::Arc;
@@ -43,13 +43,29 @@ async fn main() -> anyhow::Result<()> {
);
// Load ML models
- let state = Arc::new(
- AppState::new(&parakeet_dir, &parakeet_eou_dir, &sortformer_path)
- .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?,
- );
+ let mut app_state = AppState::new(&parakeet_dir, &parakeet_eou_dir, &sortformer_path)
+ .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?;
tracing::info!("Models loaded successfully");
+ // Initialize database (optional - server works without it)
+ if let Ok(database_url) = std::env::var("POSTGRES_CONNECTION_URI") {
+ tracing::info!("Connecting to database...");
+ match makima::db::create_pool(&database_url).await {
+ Ok(pool) => {
+ tracing::info!("Database connected successfully");
+ app_state = app_state.with_db_pool(pool);
+ }
+ Err(e) => {
+ tracing::warn!("Failed to connect to database: {}. Running without persistence.", e);
+ }
+ }
+ } else {
+ tracing::info!("POSTGRES_CONNECTION_URI not set. Running without persistence.");
+ }
+
+ let state = Arc::new(app_state);
+
// Run the server
let addr = format!("0.0.0.0:{}", port);
run_server(state, &addr).await
diff --git a/makima/src/db/mod.rs b/makima/src/db/mod.rs
new file mode 100644
index 0000000..dbfeeab
--- /dev/null
+++ b/makima/src/db/mod.rs
@@ -0,0 +1,15 @@
+//! Database module for PostgreSQL connectivity and models.
+
+pub mod models;
+pub mod repository;
+
+use sqlx::postgres::PgPoolOptions;
+use sqlx::PgPool;
+
+/// Create a database connection pool.
+pub async fn create_pool(database_url: &str) -> Result<PgPool, sqlx::Error> {
+ PgPoolOptions::new()
+ .max_connections(5)
+ .connect(database_url)
+ .await
+}
diff --git a/makima/src/db/models.rs b/makima/src/db/models.rs
new file mode 100644
index 0000000..45b0e53
--- /dev/null
+++ b/makima/src/db/models.rs
@@ -0,0 +1,101 @@
+//! Database models for the files table.
+
+use chrono::{DateTime, Utc};
+use serde::{Deserialize, Serialize};
+use sqlx::FromRow;
+use utoipa::ToSchema;
+use uuid::Uuid;
+
+/// TranscriptEntry stored in JSONB - matches frontend TranscriptEntry
+#[derive(Debug, Clone, Serialize, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct TranscriptEntry {
+ pub id: String,
+ pub speaker: String,
+ pub start: f32,
+ pub end: f32,
+ pub text: String,
+ pub is_final: bool,
+}
+
+/// File record from the database.
+#[derive(Debug, Clone, FromRow, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct File {
+ pub id: Uuid,
+ pub owner_id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ #[sqlx(json)]
+ pub transcript: Vec<TranscriptEntry>,
+ pub location: Option<String>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+/// Request payload for creating a new file.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct CreateFileRequest {
+ /// Name of the file (auto-generated if not provided)
+ pub name: Option<String>,
+ /// Optional description
+ pub description: Option<String>,
+ /// Transcript entries
+ pub transcript: Vec<TranscriptEntry>,
+ /// Storage location (e.g., s3://bucket/path) - not used yet
+ pub location: Option<String>,
+}
+
+/// Request payload for updating an existing file.
+#[derive(Debug, Deserialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct UpdateFileRequest {
+ /// New name (optional)
+ pub name: Option<String>,
+ /// New description (optional)
+ pub description: Option<String>,
+ /// New transcript (optional)
+ pub transcript: Option<Vec<TranscriptEntry>>,
+}
+
+/// Response for file list endpoint.
+#[derive(Debug, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileListResponse {
+ pub files: Vec<FileSummary>,
+ pub total: i64,
+}
+
+/// Summary of a file for list views (excludes full transcript).
+#[derive(Debug, Clone, Serialize, ToSchema)]
+#[serde(rename_all = "camelCase")]
+pub struct FileSummary {
+ pub id: Uuid,
+ pub name: String,
+ pub description: Option<String>,
+ pub transcript_count: usize,
+ /// Duration derived from last transcript end time
+ pub duration: Option<f32>,
+ pub created_at: DateTime<Utc>,
+ pub updated_at: DateTime<Utc>,
+}
+
+impl From<File> for FileSummary {
+ fn from(file: File) -> Self {
+ let duration = file
+ .transcript
+ .iter()
+ .map(|t| t.end)
+ .fold(0.0_f32, f32::max);
+ Self {
+ id: file.id,
+ name: file.name,
+ description: file.description,
+ transcript_count: file.transcript.len(),
+ duration: if duration > 0.0 { Some(duration) } else { None },
+ created_at: file.created_at,
+ updated_at: file.updated_at,
+ }
+ }
+}
diff --git a/makima/src/db/repository.rs b/makima/src/db/repository.rs
new file mode 100644
index 0000000..90cb1b9
--- /dev/null
+++ b/makima/src/db/repository.rs
@@ -0,0 +1,128 @@
+//! Repository pattern for file database operations.
+
+use chrono::Utc;
+use sqlx::PgPool;
+use uuid::Uuid;
+
+use super::models::{CreateFileRequest, File, UpdateFileRequest};
+
+/// Default owner ID for anonymous users.
+pub const ANONYMOUS_OWNER_ID: Uuid = Uuid::from_u128(0x00000000_0000_0000_0000_000000000002);
+
+/// Generate a default name based on current timestamp.
+fn generate_default_name() -> String {
+ let now = Utc::now();
+ now.format("Recording - %b %d %Y %H:%M:%S").to_string()
+}
+
+/// Create a new file record.
+pub async fn create_file(pool: &PgPool, req: CreateFileRequest) -> Result<File, sqlx::Error> {
+ let name = req.name.unwrap_or_else(generate_default_name);
+ let transcript_json = serde_json::to_value(&req.transcript).unwrap_or_default();
+
+ sqlx::query_as::<_, File>(
+ r#"
+ INSERT INTO files (owner_id, name, description, transcript, location)
+ VALUES ($1, $2, $3, $4, $5)
+ RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at
+ "#,
+ )
+ .bind(ANONYMOUS_OWNER_ID)
+ .bind(&name)
+ .bind(&req.description)
+ .bind(&transcript_json)
+ .bind(&req.location)
+ .fetch_one(pool)
+ .await
+}
+
+/// Get a file by ID.
+pub async fn get_file(pool: &PgPool, id: Uuid) -> Result<Option<File>, sqlx::Error> {
+ sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, name, description, transcript, location, created_at, updated_at
+ FROM files
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(ANONYMOUS_OWNER_ID)
+ .fetch_optional(pool)
+ .await
+}
+
+/// List all files for the owner, ordered by created_at DESC.
+pub async fn list_files(pool: &PgPool) -> Result<Vec<File>, sqlx::Error> {
+ sqlx::query_as::<_, File>(
+ r#"
+ SELECT id, owner_id, name, description, transcript, location, created_at, updated_at
+ FROM files
+ WHERE owner_id = $1
+ ORDER BY created_at DESC
+ "#,
+ )
+ .bind(ANONYMOUS_OWNER_ID)
+ .fetch_all(pool)
+ .await
+}
+
+/// Update a file by ID.
+pub async fn update_file(
+ pool: &PgPool,
+ id: Uuid,
+ req: UpdateFileRequest,
+) -> Result<Option<File>, sqlx::Error> {
+ // Get the existing file first
+ let existing = get_file(pool, id).await?;
+ let Some(existing) = existing else {
+ return Ok(None);
+ };
+
+ // Apply updates
+ let name = req.name.unwrap_or(existing.name);
+ let description = req.description.or(existing.description);
+ let transcript = req.transcript.unwrap_or(existing.transcript);
+ let transcript_json = serde_json::to_value(&transcript).unwrap_or_default();
+
+ sqlx::query_as::<_, File>(
+ r#"
+ UPDATE files
+ SET name = $3, description = $4, transcript = $5
+ WHERE id = $1 AND owner_id = $2
+ RETURNING id, owner_id, name, description, transcript, location, created_at, updated_at
+ "#,
+ )
+ .bind(id)
+ .bind(ANONYMOUS_OWNER_ID)
+ .bind(&name)
+ .bind(&description)
+ .bind(&transcript_json)
+ .fetch_optional(pool)
+ .await
+}
+
+/// Delete a file by ID.
+pub async fn delete_file(pool: &PgPool, id: Uuid) -> Result<bool, sqlx::Error> {
+ let result = sqlx::query(
+ r#"
+ DELETE FROM files
+ WHERE id = $1 AND owner_id = $2
+ "#,
+ )
+ .bind(id)
+ .bind(ANONYMOUS_OWNER_ID)
+ .execute(pool)
+ .await?;
+
+ Ok(result.rows_affected() > 0)
+}
+
+/// Count total files for owner.
+pub async fn count_files(pool: &PgPool) -> Result<i64, sqlx::Error> {
+ let result: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM files WHERE owner_id = $1")
+ .bind(ANONYMOUS_OWNER_ID)
+ .fetch_one(pool)
+ .await?;
+
+ Ok(result.0)
+}
diff --git a/makima/src/lib.rs b/makima/src/lib.rs
index 1e95d95..35d376c 100644
--- a/makima/src/lib.rs
+++ b/makima/src/lib.rs
@@ -1,4 +1,5 @@
pub mod audio;
+pub mod db;
pub mod listen;
pub mod server;
pub mod tts;
diff --git a/makima/src/server/handlers/files.rs b/makima/src/server/handlers/files.rs
new file mode 100644
index 0000000..746d66b
--- /dev/null
+++ b/makima/src/server/handlers/files.rs
@@ -0,0 +1,230 @@
+//! HTTP handlers for file CRUD operations.
+
+use axum::{
+ extract::{Path, State},
+ http::StatusCode,
+ response::IntoResponse,
+ Json,
+};
+use uuid::Uuid;
+
+use crate::db::models::{CreateFileRequest, FileListResponse, FileSummary, UpdateFileRequest};
+use crate::db::repository;
+use crate::server::messages::ApiError;
+use crate::server::state::SharedState;
+
+/// List all files for the current owner.
+#[utoipa::path(
+ get,
+ path = "/api/v1/files",
+ responses(
+ (status = 200, description = "List of files", body = FileListResponse),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn list_files(State(state): State<SharedState>) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::list_files(pool).await {
+ Ok(files) => {
+ let summaries: Vec<FileSummary> = files.into_iter().map(FileSummary::from).collect();
+ let total = summaries.len() as i64;
+ Json(FileListResponse {
+ files: summaries,
+ total,
+ })
+ .into_response()
+ }
+ Err(e) => {
+ tracing::error!("Failed to list files: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Get a single file by ID.
+#[utoipa::path(
+ get,
+ path = "/api/v1/files/{id}",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 200, description = "File details", body = crate::db::models::File),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn get_file(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::get_file(pool, id).await {
+ Ok(Some(file)) => Json(file).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to get file {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Create a new file.
+#[utoipa::path(
+ post,
+ path = "/api/v1/files",
+ request_body = CreateFileRequest,
+ responses(
+ (status = 201, description = "File created", body = crate::db::models::File),
+ (status = 400, description = "Invalid request", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn create_file(
+ State(state): State<SharedState>,
+ Json(req): Json<CreateFileRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::create_file(pool, req).await {
+ Ok(file) => (StatusCode::CREATED, Json(file)).into_response(),
+ Err(e) => {
+ tracing::error!("Failed to create file: {}", e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Update an existing file.
+#[utoipa::path(
+ put,
+ path = "/api/v1/files/{id}",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ request_body = UpdateFileRequest,
+ responses(
+ (status = 200, description = "File updated", body = crate::db::models::File),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn update_file(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+ Json(req): Json<UpdateFileRequest>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::update_file(pool, id, req).await {
+ Ok(Some(file)) => Json(file).into_response(),
+ Ok(None) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to update file {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
+
+/// Delete a file.
+#[utoipa::path(
+ delete,
+ path = "/api/v1/files/{id}",
+ params(
+ ("id" = Uuid, Path, description = "File ID")
+ ),
+ responses(
+ (status = 204, description = "File deleted"),
+ (status = 404, description = "File not found", body = ApiError),
+ (status = 503, description = "Database not configured", body = ApiError),
+ (status = 500, description = "Internal server error", body = ApiError),
+ ),
+ tag = "Files"
+)]
+pub async fn delete_file(
+ State(state): State<SharedState>,
+ Path(id): Path<Uuid>,
+) -> impl IntoResponse {
+ let Some(ref pool) = state.db_pool else {
+ return (
+ StatusCode::SERVICE_UNAVAILABLE,
+ Json(ApiError::new("DB_UNAVAILABLE", "Database not configured")),
+ )
+ .into_response();
+ };
+
+ match repository::delete_file(pool, id).await {
+ Ok(true) => StatusCode::NO_CONTENT.into_response(),
+ Ok(false) => (
+ StatusCode::NOT_FOUND,
+ Json(ApiError::new("NOT_FOUND", "File not found")),
+ )
+ .into_response(),
+ Err(e) => {
+ tracing::error!("Failed to delete file {}: {}", id, e);
+ (
+ StatusCode::INTERNAL_SERVER_ERROR,
+ Json(ApiError::new("DB_ERROR", e.to_string())),
+ )
+ .into_response()
+ }
+ }
+}
diff --git a/makima/src/server/handlers/listen.rs b/makima/src/server/handlers/listen.rs
index bf6746c..93062f3 100644
--- a/makima/src/server/handlers/listen.rs
+++ b/makima/src/server/handlers/listen.rs
@@ -9,6 +9,8 @@ use tokio::sync::mpsc;
use uuid::Uuid;
use crate::audio::{resample_and_mixdown, TARGET_CHANNELS, TARGET_SAMPLE_RATE};
+use crate::db::models::{CreateFileRequest, TranscriptEntry, UpdateFileRequest};
+use crate::db::repository;
use crate::listen::{align_speakers, samples_per_chunk, DialogueSegment, TimestampMode};
use crate::server::messages::{
AudioEncoding, ClientMessage, ServerMessage, StartMessage, TranscriptMessage,
@@ -99,6 +101,11 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
let mut audio_offset: f32 = 0.0; // Time offset from trimmed audio
let mut finalized_segments: Vec<DialogueSegment> = Vec::new();
+ // File persistence state
+ let mut file_id: Option<Uuid> = None;
+ let mut transcript_entries: Vec<TranscriptEntry> = Vec::new();
+ let mut transcript_counter: u32 = 0;
+
// Reset Sortformer state for new session
{
let mut sortformer = state.sortformer.lock().await;
@@ -329,12 +336,52 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
// Send segments with adjusted timestamps
for seg in &segments {
+ let adjusted_start = seg.start + audio_offset;
let adjusted_end = seg.end + audio_offset;
if adjusted_end > last_sent_end_time {
+ // Create file on first transcript if database is available
+ if file_id.is_none() {
+ if let Some(ref pool) = state.db_pool {
+ match repository::create_file(pool, CreateFileRequest {
+ name: None, // Auto-generated
+ description: None,
+ transcript: vec![],
+ location: None,
+ }).await {
+ Ok(file) => {
+ file_id = Some(file.id);
+ tracing::info!(
+ session_id = %session_id,
+ file_id = %file.id,
+ "Created file for session"
+ );
+ }
+ Err(e) => {
+ tracing::warn!(
+ session_id = %session_id,
+ error = %e,
+ "Failed to create file for session"
+ );
+ }
+ }
+ }
+ }
+
+ // Track transcript entry
+ transcript_counter += 1;
+ transcript_entries.push(TranscriptEntry {
+ id: format!("{}-{}", session_id, transcript_counter),
+ speaker: seg.speaker.clone(),
+ start: adjusted_start,
+ end: adjusted_end,
+ text: seg.text.clone(),
+ is_final: false,
+ });
+
let _ = response_tx
.send(ServerMessage::Transcript(TranscriptMessage {
speaker: seg.speaker.clone(),
- start: seg.start + audio_offset,
+ start: adjusted_start,
end: adjusted_end,
text: seg.text.clone(),
is_final: false,
@@ -399,6 +446,39 @@ async fn handle_socket(socket: WebSocket, state: SharedState) {
}
}
+ // Save final transcript to file if we have one
+ if let Some(fid) = file_id {
+ if let Some(ref pool) = state.db_pool {
+ // Mark all entries as final
+ for entry in &mut transcript_entries {
+ entry.is_final = true;
+ }
+
+ match repository::update_file(pool, fid, UpdateFileRequest {
+ name: None,
+ description: None,
+ transcript: Some(transcript_entries.clone()),
+ }).await {
+ Ok(_) => {
+ tracing::info!(
+ session_id = %session_id,
+ file_id = %fid,
+ transcript_count = transcript_entries.len(),
+ "Saved final transcript to file"
+ );
+ }
+ Err(e) => {
+ tracing::error!(
+ session_id = %session_id,
+ file_id = %fid,
+ error = %e,
+ "Failed to save final transcript to file"
+ );
+ }
+ }
+ }
+ }
+
// Cleanup
drop(response_tx);
let _ = sender_task.await;
diff --git a/makima/src/server/handlers/mod.rs b/makima/src/server/handlers/mod.rs
index 94b0384..f249234 100644
--- a/makima/src/server/handlers/mod.rs
+++ b/makima/src/server/handlers/mod.rs
@@ -1,3 +1,4 @@
//! HTTP and WebSocket request handlers.
+pub mod files;
pub mod listen;
diff --git a/makima/src/server/mod.rs b/makima/src/server/mod.rs
index c509afa..bc3e679 100644
--- a/makima/src/server/mod.rs
+++ b/makima/src/server/mod.rs
@@ -17,7 +17,7 @@ use tower_http::trace::TraceLayer;
use utoipa::OpenApi;
use utoipa_swagger_ui::SwaggerUi;
-use crate::server::handlers::listen;
+use crate::server::handlers::{files, listen};
use crate::server::openapi::ApiDoc;
use crate::server::state::SharedState;
@@ -43,6 +43,13 @@ pub fn make_router(state: SharedState) -> Router {
// API v1 routes
let api_v1 = Router::new()
.route("/listen", get(listen::websocket_handler))
+ .route("/files", get(files::list_files).post(files::create_file))
+ .route(
+ "/files/{id}",
+ get(files::get_file)
+ .put(files::update_file)
+ .delete(files::delete_file),
+ )
.with_state(state);
let swagger = SwaggerUi::new("/swagger-ui")
diff --git a/makima/src/server/openapi.rs b/makima/src/server/openapi.rs
index 3e8c06c..b946ff3 100644
--- a/makima/src/server/openapi.rs
+++ b/makima/src/server/openapi.rs
@@ -2,19 +2,27 @@
use utoipa::OpenApi;
-use crate::server::handlers::listen;
+use crate::db::models::{
+ CreateFileRequest, File, FileListResponse, FileSummary, TranscriptEntry, UpdateFileRequest,
+};
+use crate::server::handlers::{files, listen};
use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage, TranscriptMessage};
#[derive(OpenApi)]
#[openapi(
info(
- title = "Makima Listen API",
+ title = "Makima API",
version = "1.0.0",
- description = "Streaming audio APIs for speech-to-text.",
+ description = "Streaming audio APIs for speech-to-text with persistence.",
license(name = "MIT"),
),
paths(
listen::websocket_handler,
+ files::list_files,
+ files::get_file,
+ files::create_file,
+ files::update_file,
+ files::delete_file,
),
components(
schemas(
@@ -23,10 +31,18 @@ use crate::server::messages::{ApiError, AudioEncoding, StartMessage, StopMessage
StartMessage,
StopMessage,
TranscriptMessage,
+ // File schemas
+ File,
+ FileSummary,
+ FileListResponse,
+ CreateFileRequest,
+ UpdateFileRequest,
+ TranscriptEntry,
)
),
tags(
(name = "Listen", description = "Speech-to-text streaming endpoints"),
+ (name = "Files", description = "Transcript file management"),
)
)]
pub struct ApiDoc;
diff --git a/makima/src/server/state.rs b/makima/src/server/state.rs
index 31e1518..8cdc26c 100644
--- a/makima/src/server/state.rs
+++ b/makima/src/server/state.rs
@@ -1,11 +1,12 @@
-//! Application state holding shared ML models.
+//! Application state holding shared ML models and database pool.
use std::sync::Arc;
+use sqlx::PgPool;
use tokio::sync::Mutex;
use crate::listen::{DiarizationConfig, ParakeetEOU, ParakeetTDT, Sortformer};
-/// Shared application state containing ML models.
+/// Shared application state containing ML models and database pool.
///
/// Models are wrapped in `Mutex` for thread-safe mutable access during inference.
pub struct AppState {
@@ -15,6 +16,8 @@ pub struct AppState {
pub parakeet_eou: Mutex<ParakeetEOU>,
/// Speaker diarization model (Sortformer)
pub sortformer: Mutex<Sortformer>,
+ /// Optional database connection pool
+ pub db_pool: Option<PgPool>,
}
impl AppState {
@@ -41,8 +44,15 @@ impl AppState {
parakeet: Mutex::new(parakeet),
parakeet_eou: Mutex::new(parakeet_eou),
sortformer: Mutex::new(sortformer),
+ db_pool: None,
})
}
+
+ /// Set the database pool.
+ pub fn with_db_pool(mut self, pool: PgPool) -> Self {
+ self.db_pool = Some(pool);
+ self
+ }
}
/// Type alias for the shared application state.