From 4c519a109a40f669812d1ef00f4af3f4ed2685f8 Mon Sep 17 00:00:00 2001 From: Developer Date: Mon, 6 Apr 2026 13:42:31 -0700 Subject: [PATCH] Add missing Svelte components and stores, fix .gitignore lib/ pattern The src/lib/ directory was being excluded by a Python .gitignore rule for lib/ (meant for Python's build output). Changed to /lib/ so it only matches root-level lib/ and doesn't block src/lib/. Adds 8 files that were created but missed in the initial commit: - 5 Svelte components (Header, StatusBar, Controls, TranscriptionDisplay, Settings) - 3 TypeScript stores (backend, config, transcriptions) Co-Authored-By: Claude Opus 4.6 (1M context) --- .gitignore | 4 +- src/lib/components/Controls.svelte | 116 +++ src/lib/components/Header.svelte | 82 ++ src/lib/components/Settings.svelte | 780 ++++++++++++++++++ src/lib/components/StatusBar.svelte | 106 +++ .../components/TranscriptionDisplay.svelte | 110 +++ src/lib/stores/backend.ts | 266 ++++++ src/lib/stores/config.ts | 243 ++++++ src/lib/stores/transcriptions.ts | 109 +++ 9 files changed, 1814 insertions(+), 2 deletions(-) create mode 100644 src/lib/components/Controls.svelte create mode 100644 src/lib/components/Header.svelte create mode 100644 src/lib/components/Settings.svelte create mode 100644 src/lib/components/StatusBar.svelte create mode 100644 src/lib/components/TranscriptionDisplay.svelte create mode 100644 src/lib/stores/backend.ts create mode 100644 src/lib/stores/config.ts create mode 100644 src/lib/stores/transcriptions.ts diff --git a/.gitignore b/.gitignore index 0ec9028..ac27095 100644 --- a/.gitignore +++ b/.gitignore @@ -10,8 +10,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ -lib64/ +/lib/ +/lib64/ parts/ sdist/ var/ diff --git a/src/lib/components/Controls.svelte b/src/lib/components/Controls.svelte new file mode 100644 index 0000000..d3679eb --- /dev/null +++ b/src/lib/components/Controls.svelte @@ -0,0 +1,116 @@ + + +
+ + + + + +
+ + diff --git a/src/lib/components/Header.svelte b/src/lib/components/Header.svelte new file mode 100644 index 0000000..1fcc632 --- /dev/null +++ b/src/lib/components/Header.svelte @@ -0,0 +1,82 @@ + + +
+

Local Transcription

+ +
+ + diff --git a/src/lib/components/Settings.svelte b/src/lib/components/Settings.svelte new file mode 100644 index 0000000..7cb7162 --- /dev/null +++ b/src/lib/components/Settings.svelte @@ -0,0 +1,780 @@ + + + + + + + + diff --git a/src/lib/components/StatusBar.svelte b/src/lib/components/StatusBar.svelte new file mode 100644 index 0000000..cc482df --- /dev/null +++ b/src/lib/components/StatusBar.svelte @@ -0,0 +1,106 @@ + + +
+
+ + {backendStore.stateMessage} +
+
+ {#if backendStore.deviceInfo} + {backendStore.deviceInfo} + | + {/if} + {userName} +
+
+ + diff --git a/src/lib/components/TranscriptionDisplay.svelte b/src/lib/components/TranscriptionDisplay.svelte new file mode 100644 index 0000000..77d86a5 --- /dev/null +++ b/src/lib/components/TranscriptionDisplay.svelte @@ -0,0 +1,110 @@ + + +
+ {#each items as item (item.id)} +
+ {#if showTimestamps && item.timestamp} + [{item.timestamp}] + {/if} + {#if item.userName} + {item.userName}: + {/if} + {#if item.isPreview} + [...] + {/if} + {item.text} +
+ {:else} +
+ Transcriptions will appear here... +
+ {/each} +
+ + diff --git a/src/lib/stores/backend.ts b/src/lib/stores/backend.ts new file mode 100644 index 0000000..f0293bc --- /dev/null +++ b/src/lib/stores/backend.ts @@ -0,0 +1,266 @@ +/** + * Backend store - manages WebSocket connection and REST API communication + * with the Python backend server running on localhost. + * + * The backend port defaults to 8081 but can be updated at runtime via + * `setPort()`. The WebSocket connects to /ws/control for real-time push + * of transcriptions, previews, and state changes. + */ + +export type ConnectionState = "connecting" | "connected" | "disconnected" | "error"; +export type AppState = "initializing" | "ready" | "transcribing" | "reloading" | "error"; + +interface BackendState { + port: number; + connectionState: ConnectionState; + appState: AppState; + stateMessage: string; + deviceInfo: string; + wsConnection: WebSocket | null; + version: string; + lastError: string; +} + +let state = $state({ + port: 8081, + connectionState: "disconnected", + appState: "initializing", + stateMessage: "Connecting to backend...", + deviceInfo: "", + wsConnection: null, + version: "1.4.0", + lastError: "", +}); + +let reconnectTimer: ReturnType | null = null; +let reconnectAttempts = 0; +const MAX_RECONNECT_DELAY_MS = 30_000; +const BASE_RECONNECT_DELAY_MS = 1_000; + +// ── URL helpers ────────────────────────────────────────────────────── + +function apiUrl(path: string): string { + const normalised = path.startsWith("/") ? path : `/${path}`; + return `http://localhost:${state.port}${normalised}`; +} + +async function apiFetch(path: string, options?: RequestInit): Promise { + const url = apiUrl(path); + const method = options?.method?.toUpperCase() ?? "GET"; + const headers = new Headers(options?.headers); + if (method !== "GET" && !headers.has("Content-Type")) { + headers.set("Content-Type", "application/json"); + } + return fetch(url, { ...options, headers }); +} + +// ── WebSocket management ───────────────────────────────────────────── + +function connectWebSocket() { + // Tear down any existing connection + disconnect(); + + state.connectionState = "connecting"; + reconnectAttempts = 0; + + _openSocket(); +} + +function _openSocket() { + const wsUrl = `ws://localhost:${state.port}/ws/control`; + + try { + const ws = new WebSocket(wsUrl); + + ws.onopen = () => { + state.connectionState = "connected"; + state.lastError = ""; + reconnectAttempts = 0; + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + }; + + ws.onmessage = (event) => { + try { + const data = JSON.parse(event.data); + handleWebSocketMessage(data); + } catch { + // ignore parse errors + } + }; + + ws.onclose = () => { + state.wsConnection = null; + if (state.connectionState !== "disconnected") { + state.connectionState = "error"; + state.stateMessage = "Disconnected from backend"; + _scheduleReconnect(); + } + }; + + ws.onerror = () => { + state.lastError = "WebSocket error"; + // onclose fires after this, which handles reconnect + }; + + state.wsConnection = ws; + } catch { + state.connectionState = "error"; + state.stateMessage = "Failed to connect"; + _scheduleReconnect(); + } +} + +function _scheduleReconnect() { + if (reconnectTimer) return; + + const delay = Math.min( + BASE_RECONNECT_DELAY_MS * Math.pow(2, reconnectAttempts), + MAX_RECONNECT_DELAY_MS, + ); + reconnectAttempts++; + + reconnectTimer = setTimeout(() => { + reconnectTimer = null; + if (state.connectionState !== "disconnected") { + state.connectionState = "connecting"; + _openSocket(); + } + }, delay); +} + +function disconnect() { + if (reconnectTimer) { + clearTimeout(reconnectTimer); + reconnectTimer = null; + } + state.connectionState = "disconnected"; + if (state.wsConnection) { + const ws = state.wsConnection; + ws.onclose = null; + ws.onerror = null; + ws.close(); + state.wsConnection = null; + } +} + +// ── WebSocket message handling ─────────────────────────────────────── + +function handleWebSocketMessage(data: Record) { + // Handle state changes locally + if (data.type === "state_changed") { + if (data.state) { + state.appState = data.state as AppState; + } + if (data.message) { + state.stateMessage = data.message as string; + } + } + + if (data.type === "error") { + state.lastError = (data.message as string) ?? "Unknown error"; + } + + // Dispatch to window for other stores (transcriptions, etc.) + if (data.type === "transcription") { + window.dispatchEvent( + new CustomEvent("backend:transcription", { detail: data }) + ); + } else if (data.type === "preview") { + window.dispatchEvent( + new CustomEvent("backend:preview", { detail: data }) + ); + } else if (data.type === "credits_low") { + window.dispatchEvent( + new CustomEvent("backend:credits_low", { detail: data }) + ); + } +} + +// ── Port management ────────────────────────────────────────────────── + +function setPort(newPort: number) { + if (newPort === state.port) return; + state.port = newPort; + // Reconnect with new port if we had a connection + if (state.connectionState !== "disconnected") { + connectWebSocket(); + } +} + +// ── Typed REST helpers ─────────────────────────────────────────────── + +async function apiGet(path: string): Promise { + const resp = await apiFetch(path); + if (!resp.ok) throw new Error(`GET ${path} failed: ${resp.status}`); + return resp.json(); +} + +async function apiPost( + path: string, + body?: unknown +): Promise { + const resp = await apiFetch(path, { + method: "POST", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!resp.ok) throw new Error(`POST ${path} failed: ${resp.status}`); + return resp.json(); +} + +async function apiPut( + path: string, + body?: unknown +): Promise { + const resp = await apiFetch(path, { + method: "PUT", + body: body !== undefined ? JSON.stringify(body) : undefined, + }); + if (!resp.ok) throw new Error(`PUT ${path} failed: ${resp.status}`); + return resp.json(); +} + +// ── Public API ─────────────────────────────────────────────────────── + +export const backendStore = { + get port() { + return state.port; + }, + get connectionState() { + return state.connectionState; + }, + get connected() { + return state.connectionState === "connected"; + }, + get appState() { + return state.appState; + }, + get stateMessage() { + return state.stateMessage; + }, + get deviceInfo() { + return state.deviceInfo; + }, + get version() { + return state.version; + }, + get lastError() { + return state.lastError; + }, + get apiBaseUrl() { + return `http://localhost:${state.port}`; + }, + get wsUrl() { + return `ws://localhost:${state.port}/ws/control`; + }, + setPort, + connect: connectWebSocket, + disconnect, + apiUrl, + apiFetch, + apiGet, + apiPost, + apiPut, +}; diff --git a/src/lib/stores/config.ts b/src/lib/stores/config.ts new file mode 100644 index 0000000..9312bba --- /dev/null +++ b/src/lib/stores/config.ts @@ -0,0 +1,243 @@ +/** + * Config store - manages application configuration loaded from + * and saved to the Python backend via the backend store's API helpers. + * + * The backend accepts PUT /api/config with `{ settings: { "dot.key": value } }`. + */ + +import { backendStore } from "$lib/stores/backend"; + +export interface AppConfig { + user: { + name: string; + id: string; + }; + audio: { + input_device: string; + sample_rate: number; + }; + transcription: { + model: string; + device: string; + language: string; + compute_type: string; + enable_realtime_transcription: boolean; + realtime_model: string; + realtime_processing_pause: number; + silero_sensitivity: number; + silero_use_onnx: boolean; + webrtc_sensitivity: number; + post_speech_silence_duration: number; + min_length_of_recording: number; + min_gap_between_recordings: number; + pre_recording_buffer_duration: number; + beam_size: number; + initial_prompt: string; + no_log_file: boolean; + continuous_mode: boolean; + }; + server_sync: { + enabled: boolean; + url: string; + room: string; + passphrase: string; + }; + display: { + show_timestamps: boolean; + max_lines: number; + font_source: string; + font_family: string; + websafe_font: string; + google_font: string; + custom_font_file: string; + font_size: number; + theme: string; + fade_after_seconds: number; + user_color: string; + text_color: string; + background_color: string; + }; + web_server: { + port: number; + host: string; + }; + remote: { + mode: string; + server_url: string; + auth_token: string; + byok_api_key: string; + deepgram_model: string; + language: string; + fallback_to_local: boolean; + }; + updates: { + auto_check: boolean; + gitea_url: string; + owner: string; + repo: string; + skipped_versions: string[]; + last_check: string; + check_interval_hours: number; + }; +} + +function getDefaultConfig(): AppConfig { + return { + user: { name: "User", id: "" }, + audio: { input_device: "default", sample_rate: 16000 }, + transcription: { + model: "base.en", + device: "auto", + language: "en", + compute_type: "default", + enable_realtime_transcription: false, + realtime_model: "tiny.en", + realtime_processing_pause: 0.1, + silero_sensitivity: 0.4, + silero_use_onnx: true, + webrtc_sensitivity: 3, + post_speech_silence_duration: 0.3, + min_length_of_recording: 0.5, + min_gap_between_recordings: 0, + pre_recording_buffer_duration: 0.2, + beam_size: 5, + initial_prompt: "", + no_log_file: true, + continuous_mode: false, + }, + server_sync: { + enabled: false, + url: "http://localhost:3000/api/send", + room: "default", + passphrase: "", + }, + display: { + show_timestamps: true, + max_lines: 100, + font_source: "System Font", + font_family: "Courier", + websafe_font: "Arial", + google_font: "Roboto", + custom_font_file: "", + font_size: 12, + theme: "dark", + fade_after_seconds: 10, + user_color: "#4CAF50", + text_color: "#FFFFFF", + background_color: "#000000B3", + }, + web_server: { port: 8080, host: "127.0.0.1" }, + remote: { + mode: "local", + server_url: "", + auth_token: "", + byok_api_key: "", + deepgram_model: "nova-2", + language: "en-US", + fallback_to_local: true, + }, + updates: { + auto_check: true, + gitea_url: "https://repo.anhonesthost.net", + owner: "streamer-tools", + repo: "local-transcription", + skipped_versions: [], + last_check: "", + check_interval_hours: 24, + }, + }; +} + +let config = $state(getDefaultConfig()); +let loading = $state(false); +let error = $state(""); + +/** + * Fetch the full configuration tree from the backend. + * GET /api/config + */ +async function fetchConfig(): Promise { + loading = true; + error = ""; + + try { + const data = await backendStore.apiGet>("/api/config"); + // Deep merge with defaults to ensure all keys exist + config = deepMerge(getDefaultConfig(), data) as AppConfig; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + console.error("[config] fetchConfig failed:", error); + } finally { + loading = false; + } +} + +function deepMerge(target: Record, source: Record): Record { + const result = { ...target }; + for (const key of Object.keys(source)) { + if ( + source[key] && + typeof source[key] === "object" && + !Array.isArray(source[key]) && + target[key] && + typeof target[key] === "object" && + !Array.isArray(target[key]) + ) { + result[key] = deepMerge( + target[key] as Record, + source[key] as Record + ); + } else { + result[key] = source[key]; + } + } + return result; +} + +/** + * Send a batch of setting updates to the backend. + * PUT /api/config with body `{ settings: { "dot.key": value, ... } }` + * + * Keys use dot-notation, e.g. `{ "transcription.model": "small.en" }`. + * + * Returns the response payload on success, or throws on failure. + */ +async function updateConfig( + settings: Record, +): Promise<{ status: string; message: string; engine_reloaded: boolean }> { + loading = true; + error = ""; + + try { + const result = await backendStore.apiPut<{ + status: string; + message: string; + engine_reloaded: boolean; + }>("/api/config", { settings }); + + // Refresh the local config tree so the UI stays in sync + await fetchConfig(); + + return result; + } catch (err) { + error = err instanceof Error ? err.message : String(err); + console.error("[config] updateConfig failed:", error); + throw err; + } finally { + loading = false; + } +} + +export const configStore = { + get config() { + return config; + }, + get loading() { + return loading; + }, + get error() { + return error; + }, + fetchConfig, + updateConfig, +}; diff --git a/src/lib/stores/transcriptions.ts b/src/lib/stores/transcriptions.ts new file mode 100644 index 0000000..f7c43c7 --- /dev/null +++ b/src/lib/stores/transcriptions.ts @@ -0,0 +1,109 @@ +/** + * Transcriptions store - manages the list of transcription items + * received from the backend via WebSocket. + */ + +export interface TranscriptionItem { + id: string; + text: string; + userName: string; + timestamp: string; + isPreview: boolean; +} + +let items = $state([]); +let nextId = 0; + +function generateId(): string { + return `t-${Date.now()}-${nextId++}`; +} + +function addTranscription(data: { + text?: string; + user_name?: string; + timestamp?: string; +}) { + // When a final transcription arrives, remove any existing preview + const previewIndex = items.findIndex((item) => item.isPreview); + if (previewIndex !== -1) { + items.splice(previewIndex, 1); + } + + items.push({ + id: generateId(), + text: data.text ?? "", + userName: data.user_name ?? "", + timestamp: data.timestamp ?? "", + isPreview: false, + }); + + // Keep a reasonable limit + if (items.length > 500) { + items.splice(0, items.length - 500); + } +} + +function setPreview(data: { + text?: string; + user_name?: string; + timestamp?: string; +}) { + const existingIndex = items.findIndex((item) => item.isPreview); + const previewItem: TranscriptionItem = { + id: existingIndex !== -1 ? items[existingIndex].id : generateId(), + text: data.text ?? "", + userName: data.user_name ?? "", + timestamp: data.timestamp ?? "", + isPreview: true, + }; + + if (existingIndex !== -1) { + items[existingIndex] = previewItem; + } else { + items.push(previewItem); + } +} + +function clearAll() { + items.length = 0; +} + +function getPlainText(): string { + return items + .filter((item) => !item.isPreview) + .map((item) => { + let line = ""; + if (item.timestamp) line += `[${item.timestamp}] `; + if (item.userName) line += `${item.userName}: `; + line += item.text; + return line; + }) + .join("\n"); +} + +// Listen for backend events +if (typeof window !== "undefined") { + window.addEventListener("backend:transcription", ((e: CustomEvent) => { + addTranscription(e.detail); + }) as EventListener); + + window.addEventListener("backend:preview", ((e: CustomEvent) => { + setPreview(e.detail); + }) as EventListener); +} + +export const transcriptionStore = { + get items() { + return items; + }, + get currentPreview(): TranscriptionItem | null { + return items.find((item) => item.isPreview) ?? null; + }, + get transcriptions(): TranscriptionItem[] { + return items.filter((item) => !item.isPreview); + }, + addTranscription, + setPreview, + clearAll, + getPlainText, +};