diff options
Diffstat (limited to 'makima')
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" + > + ← 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(¶keet_dir, ¶keet_eou_dir, &sortformer_path) - .map_err(|e| anyhow::anyhow!("Failed to load models: {}", e))?, - ); + let mut app_state = AppState::new(¶keet_dir, ¶keet_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. |
