diff --git a/client/src/pages/Settings.tsx b/client/src/pages/Settings.tsx index d24c4dd..7fa0996 100644 --- a/client/src/pages/Settings.tsx +++ b/client/src/pages/Settings.tsx @@ -38,6 +38,15 @@ import type { DocumentUploadOk, DocumentDownloadOk, UserDocument, + UserPreferencesOk, + PreferencesOptionsOk, + VoiceOption, + SavedEncountersListOk, + SavedEncounterRow, + AudioBackupsListOk, + AudioBackupRow, + MemoriesOk, + MemoryRow, } from '@/shared/types'; // ── Small presentational bits ──────────────────────────────── @@ -919,6 +928,803 @@ function DocumentsCard() { ); } +// ── Voice Preferences (STT model + TTS voice) ─────────────── +function VoicePreferencesCard() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(null); + const [sttModel, setSttModel] = useState(''); + const [ttsVoice, setTtsVoice] = useState(''); + const [previewing, setPreviewing] = useState(false); + const [hydrated, setHydrated] = useState(false); + + const { data: options } = useQuery({ + queryKey: ['voice-options'], + queryFn: () => api.get('/api/user/preferences/options'), + }); + const { data: prefs } = useQuery({ + queryKey: ['voice-prefs'], + queryFn: () => api.get('/api/user/preferences'), + }); + + // Hydrate selection from the server once — subsequent typing is local. + if (!hydrated && prefs) { + setSttModel(prefs.stt_model || ''); + setTtsVoice(prefs.tts_voice || ''); + setHydrated(true); + } + + const save = useMutation({ + mutationFn: (body: { stt_model: string | null; tts_voice: string | null }) => + api.post<{ success: true }>('/api/user/preferences', body), + onSuccess: () => { + setMsg({ text: 'Voice preferences saved', kind: 'ok' }); + qc.invalidateQueries({ queryKey: ['voice-prefs'] }); + }, + onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }), + }); + + async function previewVoice() { + setMsg(null); + setPreviewing(true); + try { + // First persist the selection so the server renders with the chosen voice. + await api.post('/api/user/preferences', { tts_voice: ttsVoice || null }); + qc.invalidateQueries({ queryKey: ['voice-prefs'] }); + + const text = + 'Hello, this is a preview of the ' + + (ttsVoice || 'server default') + + ' voice. This is how your read-aloud feature will sound.'; + const resp = await fetch('/api/text-to-speech', { + method: 'POST', + credentials: 'include', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ text }), + }); + if (!resp.ok) throw new Error('Preview failed'); + const blob = await resp.blob(); + const url = URL.createObjectURL(blob); + const audio = new Audio(url); + audio.onended = () => URL.revokeObjectURL(url); + await audio.play(); + } catch (e) { + setMsg({ text: 'Preview failed: ' + (e as Error).message, kind: 'err' }); + } finally { + setPreviewing(false); + } + } + + return ( +
+

Voice Preferences

+

+ Customize your speech-to-text model and text-to-speech voice. These settings apply to all recording and read-aloud features. +

+ +
+ + + + + +
+ + +
+ ); +} + +// ── Browser Whisper (WASM, local transcription) ───────────── +// UI-only port: persists enabled + model choice in localStorage under the +// same keys the vanilla BrowserWhisper module reads. The actual WASM +// model preload / transcription still lives in the vanilla +// public/js/browserWhisper.js module and will wire through when the +// recording components port to React. +const BW_ENABLED_KEY = 'ped_browser_whisper_enabled'; +const BW_MODEL_KEY = 'ped_browser_whisper_model'; +const BW_MODELS: VoiceOption[] = [ + { value: 'Xenova/whisper-tiny.en', label: 'Tiny (~39MB) — fastest, ~2-3s' }, + { value: 'Xenova/whisper-base.en', label: 'Base (~74MB) — balanced, ~3-5s' }, + { value: 'Xenova/whisper-small.en', label: 'Small (~244MB) — best quality, ~6-10s' }, +]; +function readLS(k: string, fallback = ''): string { + try { return window.localStorage.getItem(k) ?? fallback; } catch { return fallback; } +} +function writeLS(k: string, v: string) { + try { window.localStorage.setItem(k, v); } catch { /* ignore */ } +} + +function BrowserWhisperCard() { + const [enabled, setEnabled] = useState(readLS(BW_ENABLED_KEY) === 'true'); + const [model, setModel] = useState(readLS(BW_MODEL_KEY) || BW_MODELS[0].value); + + function toggle(next: boolean) { + setEnabled(next); + writeLS(BW_ENABLED_KEY, String(next)); + } + function changeModel(next: string) { + setModel(next); + writeLS(BW_MODEL_KEY, next); + } + + return ( +
+

Browser Transcription (Local Whisper)

+

+ Transcribes audio entirely in your browser — no audio sent to any server. Powered by OpenAI Whisper running in WebAssembly. Model is downloaded once and cached locally. +

+
+ + +
+
+ + +
+

+ When enabled, overrides server transcription. Falls back to server if browser transcription fails. The actual WASM download runs from the recording components — pre-download will light up when those port to React. +

+
+ ); +} + +// ── Web Speech Recognition (real-time streaming) ──────────── +const WS_ENABLED_KEY = 'ped_web_speech_enabled'; + +function WebSpeechCard() { + const [enabled, setEnabled] = useState(readLS(WS_ENABLED_KEY) === 'true'); + const [confirmOpen, setConfirmOpen] = useState(false); + + const supported = + typeof window !== 'undefined' && + ('webkitSpeechRecognition' in window || 'SpeechRecognition' in window); + + function enable() { + // Enabling Web Speech flips Browser Whisper off (the vanilla app enforces + // the same priority: Web Speech > Browser Whisper > server). + writeLS(BW_ENABLED_KEY, 'false'); + setEnabled(true); + writeLS(WS_ENABLED_KEY, 'true'); + } + function disable() { + setEnabled(false); + writeLS(WS_ENABLED_KEY, 'false'); + } + + return ( +
+

Real-Time Streaming Transcription

+
+ Privacy Warning: Uses your browser's built-in speech recognition, which may send audio to cloud servers (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription. +
+

+ See words appear as you speak (streaming). Overrides browser and server transcription when enabled. Not HIPAA-compliant in most browsers. +

+
+ + +
+ {supported && ( +

+ Streaming integration lights up when the recording components port to React. +

+ )} + + { + enable(); + setConfirmOpen(false); + }} + onCancel={() => setConfirmOpen(false)} + /> +
+ ); +} + +// ── My Templates (Memories, non-correction entries) ───────── +const TEMPLATE_CATEGORIES: Array<{ value: string; label: string }> = [ + { value: 'physical_exam', label: 'Physical Exam Template' }, + { value: 'ros', label: 'Review of Systems Template' }, + { value: 'encounter_format', label: 'Encounter Note Format' }, + { value: 'family_history', label: 'Family History Format' }, + { value: 'assessment_plan', label: 'Assessment & Plan Format' }, + { value: 'template_soap', label: 'SOAP Note Template' }, + { value: 'template_hpi', label: 'HPI Template' }, + { value: 'template_wellvisit', label: 'Well Visit Template' }, + { value: 'template_sickvisit', label: 'Sick Visit Template' }, + { value: 'custom', label: 'Custom' }, +]; +const CATEGORY_LABEL: Record = Object.fromEntries( + TEMPLATE_CATEGORIES.map((c) => [c.value, c.label.replace(/ Template$| Format$/, '')]) +); + +function TemplatesCard() { + const qc = useQueryClient(); + const [msg, setMsg] = useState(null); + const [editingId, setEditingId] = useState(null); + const [category, setCategory] = useState(TEMPLATE_CATEGORIES[0].value); + const [name, setName] = useState(''); + const [content, setContent] = useState(''); + const [deleteTarget, setDeleteTarget] = useState(null); + + const { data } = useQuery({ + queryKey: ['memories'], + queryFn: () => api.get('/api/memories'), + }); + + const templates = (data?.memories || []).filter((m) => !m.category.startsWith('correction_')); + + const save = useMutation({ + mutationFn: (body: { id?: number; category: string; name: string; content: string }) => { + const { id, ...rest } = body; + return id + ? api.put<{ success: true }>('/api/memories/' + id, rest) + : api.post<{ success: true }>('/api/memories', rest); + }, + onSuccess: () => { + setMsg({ text: editingId ? 'Template updated' : 'Template saved', kind: 'ok' }); + setEditingId(null); + setName(''); + setContent(''); + qc.invalidateQueries({ queryKey: ['memories'] }); + }, + onError: (e: Error) => setMsg({ text: e.message || 'Save failed', kind: 'err' }), + }); + + const del = useMutation({ + mutationFn: (id: number) => api.delete<{ success: true }>('/api/memories/' + id), + onSuccess: () => { + setMsg({ text: 'Template deleted', kind: 'info' }); + qc.invalidateQueries({ queryKey: ['memories'] }); + }, + onError: (e: Error) => setMsg({ text: e.message || 'Delete failed', kind: 'err' }), + }); + + function beginEdit(m: MemoryRow) { + setEditingId(m.id); + setCategory(m.category); + setName(m.name); + setContent(m.content); + } + function cancelEdit() { + setEditingId(null); + setName(''); + setContent(''); + } + function submit(e: React.FormEvent) { + e.preventDefault(); + setMsg(null); + if (!name.trim()) return setMsg({ text: 'Enter a template name', kind: 'err' }); + if (!content.trim()) return setMsg({ text: 'Enter template content', kind: 'err' }); + save.mutate({ id: editingId ?? undefined, category, name: name.trim(), content: content.trim() }); + } + + return ( +
+

My Templates

+

+ Save reusable templates for physical exam, ROS, encounter format, etc. The AI will use these when generating notes. +

+ +
+
+ + setName(e.target.value)} + data-testid="mem-name" + /> +
+