From cd1f762f3425bf3cdeaf88da9b305c96361fb068 Mon Sep 17 00:00:00 2001
From: Daniel
Date: Fri, 24 Apr 2026 03:45:28 +0200
Subject: [PATCH] feat(notes): restore audio recording + save/load across all 7
note pages
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Recovered from git history (commit be14578) — the vanilla recording UI
was deleted during the "minimum-viable" note ports without being
re-implemented. Every note page now gets the full vanilla behavior
back:
Recording (Encounter, Dictation, SOAP, Sick Visit, Hospital Course):
• AudioRecorder — MediaRecorder mono/16kHz/EC+NS/opus 32kbps
• Pause / Resume (native where supported, restart-on-same-stream
fallback for Safari)
• Live preview via Web Speech API (opt-in in Settings)
• On stop → upload to /api/transcribe; fall back to live preview
if server unavailable or blob > 24 MB
• Failed uploads → /api/audio-backups (IndexedDB fallback)
Save / Load / New-patient toolbar (all 7 note pages):
• sessionStorage keys _savedEncId_ + _idempKey_
survive page refresh + sign-out within the same tab
• Optimistic locking via expected_version (409 → "Someone else
edited this encounter")
• Load popover lists saved encounters of matching type only
• Draft #N chip shows current session-bound row
Well Visit and Chart Review: toolbar only — vanilla had no recorder
on those tabs (paste-based workflows).
New components:
client/src/components/Recorder.tsx
client/src/components/EncounterToolbar.tsx
New libraries:
client/src/lib/recorder.ts — AudioRecorder class
client/src/lib/transcribe.ts — /api/transcribe + audio backup
client/src/lib/web-speech.ts — webkit speech preview + dedupe
client/src/lib/encounter-persistence.ts — save/load/version tracking
---
client/src/components/EncounterToolbar.tsx | 159 +++++++++++++++
client/src/components/Recorder.tsx | 215 +++++++++++++++++++++
client/src/lib/encounter-persistence.ts | 124 ++++++++++++
client/src/lib/recorder.ts | 59 ++++++
client/src/lib/transcribe.ts | 99 ++++++++++
client/src/lib/web-speech.ts | 117 +++++++++++
client/src/pages/ChartReview.tsx | 31 +++
client/src/pages/Dictation.tsx | 51 ++++-
client/src/pages/Encounter.tsx | 59 +++++-
client/src/pages/HospitalCourse.tsx | 48 +++++
client/src/pages/SickVisit.tsx | 51 ++++-
client/src/pages/Soap.tsx | 53 ++++-
client/src/pages/WellVisit.tsx | 34 ++++
13 files changed, 1077 insertions(+), 23 deletions(-)
create mode 100644 client/src/components/EncounterToolbar.tsx
create mode 100644 client/src/components/Recorder.tsx
create mode 100644 client/src/lib/encounter-persistence.ts
create mode 100644 client/src/lib/recorder.ts
create mode 100644 client/src/lib/transcribe.ts
create mode 100644 client/src/lib/web-speech.ts
diff --git a/client/src/components/EncounterToolbar.tsx b/client/src/components/EncounterToolbar.tsx
new file mode 100644
index 0000000..3374201
--- /dev/null
+++ b/client/src/components/EncounterToolbar.tsx
@@ -0,0 +1,159 @@
+// ============================================================
+// Encounter Toolbar — Save / New Patient / Load + label input.
+// Mirrors the vanilla saveFromTab / clearTab flow from
+// public/js/encounters.js. Each note page gets one of these so
+// transcripts + generated notes persist across sign-outs.
+// ============================================================
+
+import { useEffect, useState } from 'react';
+import {
+ saveEncounter, loadEncounter, listSavedEncounters,
+ getSavedEncId, clearTabState,
+ type EncType, type SavedEncounterListEntry, type LoadedEncounter,
+} from '@/lib/encounter-persistence';
+
+const input = 'rounded-md border border-input bg-background px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-ring';
+const btn = 'inline-flex items-center gap-1 rounded-md border border-border bg-background px-3 py-1.5 text-xs font-medium hover:bg-muted disabled:opacity-50';
+const btnPrimary = 'inline-flex items-center gap-1 rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-xs font-medium disabled:opacity-50';
+
+interface Props {
+ type: EncType;
+ label: string;
+ setLabel: (s: string) => void;
+ transcript: string;
+ generatedNote: string;
+ partialData?: unknown;
+ onLoad: (enc: LoadedEncounter) => void;
+ onClear: () => void;
+}
+
+export default function EncounterToolbar({
+ type, label, setLabel, transcript, generatedNote, partialData, onLoad, onClear,
+}: Props) {
+ const [msg, setMsg] = useState<{ kind: 'ok' | 'err' | 'info'; text: string } | null>(null);
+ const [saving, setSaving] = useState(false);
+ const [popoverOpen, setPopoverOpen] = useState(false);
+ const [saved, setSaved] = useState([]);
+ const [loadingList, setLoadingList] = useState(false);
+ const [search, setSearch] = useState('');
+ const [savedId, setSavedId] = useState(getSavedEncId(type));
+
+ // Sync the savedId chip whenever sessionStorage changes (e.g. after Load).
+ useEffect(() => {
+ const v = getSavedEncId(type);
+ setSavedId(v);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [transcript, generatedNote]);
+
+ async function save() {
+ if (!label.trim()) { setMsg({ kind: 'err', text: 'Enter a patient label first' }); return; }
+ setMsg(null); setSaving(true);
+ try {
+ const r = await saveEncounter({ type, label, transcript, generatedNote, partialData });
+ setSavedId(r.id);
+ setMsg({ kind: 'ok', text: 'Saved (' + label + ')' });
+ } catch (e) {
+ setMsg({ kind: 'err', text: (e as Error).message });
+ } finally { setSaving(false); }
+ }
+
+ function newPatient() {
+ clearTabState(type);
+ setSavedId(null);
+ setLabel('');
+ onClear();
+ setMsg({ kind: 'info', text: 'New patient — fields cleared.' });
+ }
+
+ async function openLoad() {
+ if (popoverOpen) { setPopoverOpen(false); return; }
+ setPopoverOpen(true);
+ setLoadingList(true);
+ try {
+ const list = await listSavedEncounters();
+ setSaved(list.filter((e) => e.enc_type === type));
+ } finally { setLoadingList(false); }
+ }
+ async function pick(id: number) {
+ try {
+ const enc = await loadEncounter(id);
+ setSavedId(id);
+ onLoad(enc);
+ setPopoverOpen(false);
+ setMsg({ kind: 'ok', text: 'Loaded ' + (enc.label || '') });
+ } catch (e) {
+ setMsg({ kind: 'err', text: (e as Error).message });
+ }
+ }
+
+ const filtered = search
+ ? saved.filter((e) => (e.label || '').toLowerCase().includes(search.toLowerCase()))
+ : saved;
+
+ return (
+
+
+
setLabel(e.target.value)}
+ data-testid="enc-label"
+ />
+
+
+
+ {popoverOpen && (
+
+
+ setSearch(e.target.value)}
+ autoFocus
+ data-testid="enc-load-search"
+ />
+
+ {loadingList &&
Loading…
}
+ {!loadingList && filtered.length === 0 && (
+
No saved {type} encounters.
+ )}
+ {filtered.map((e) => (
+
+ ))}
+
+ )}
+
+
+ {savedId != null && (
+
Draft #{savedId}
+ )}
+
+ {msg && (
+
{msg.text}
+ )}
+
+ );
+}
diff --git a/client/src/components/Recorder.tsx b/client/src/components/Recorder.tsx
new file mode 100644
index 0000000..ca5ea28
--- /dev/null
+++ b/client/src/components/Recorder.tsx
@@ -0,0 +1,215 @@
+// ============================================================
+// Recorder — shared mic-capture component for every note page.
+// Faithful React port of public/js/liveEncounter.js (and the
+// equivalent dictation/SOAP recording wrappers). Mirror behavior:
+// • Mic permission via getUserMedia (mono / 16 kHz / EC+NS)
+// • Live preview via Web Speech API (when enabled by user)
+// • Pause / Resume native MediaRecorder where supported
+// • On Stop → upload blob to /api/transcribe
+// • If transcription unavailable, fall back to the live preview text
+// • Failed uploads → /api/audio-backups (or IndexedDB) for retry
+//
+// The recorder is dumb about persistence — the parent note page
+// owns the transcript text and uses encounter-persistence.ts to
+// save/resume across sign-outs.
+// ============================================================
+
+import { useEffect, useRef, useState } from 'react';
+import { AudioRecorder } from '@/lib/recorder';
+import { transcribeAudio, isTranscribeAvailable, checkTranscribeStatus } from '@/lib/transcribe';
+import { createSpeechSession, isSpeechRecognitionEnabled, deduplicateFinal, type SpeechHandle } from '@/lib/web-speech';
+
+interface Props {
+ module: string; // 'encounter' | 'dictation' | 'soap' | …
+ // Called when transcription completes. The text replaces (or merges with)
+ // whatever the parent currently has in the transcript field.
+ onTranscript: (text: string, meta: { provider?: string; durationSec: number; appended: boolean }) => void;
+ // Called continuously with the live (interim) preview while recording.
+ // Parents can show a faded "interim" string concatenated to the
+ // confirmed transcript for instant visual feedback.
+ onInterim?: (text: string) => void;
+ // Called when transcription fails (so the parent can decide what to
+ // do — typically appending the live preview text instead).
+ onError?: (msg: string) => void;
+ disabled?: boolean;
+}
+
+const btnRecord = 'inline-flex items-center gap-2 px-4 py-2 rounded-md text-sm font-semibold border transition-colors';
+
+export default function Recorder({ module, onTranscript, onInterim, onError, disabled }: Props) {
+ const recorderRef = useRef(null);
+ const speechRef = useRef(null);
+ const finalTextRef = useRef('');
+ const intervalRef = useRef(null);
+ const startTimeRef = useRef(0);
+ const pauseAccumRef = useRef(0);
+ const pauseStartRef = useRef(0);
+
+ const [state, setState] = useState<'idle' | 'recording' | 'paused' | 'transcribing'>('idle');
+ const [seconds, setSeconds] = useState(0);
+
+ useEffect(() => {
+ if (isTranscribeAvailable() === null) checkTranscribeStatus();
+ }, []);
+
+ // Stop everything cleanly on unmount (page navigation while recording).
+ useEffect(() => {
+ return () => {
+ if (intervalRef.current) window.clearInterval(intervalRef.current);
+ if (speechRef.current) speechRef.current.stop();
+ if (recorderRef.current) recorderRef.current.stop().catch(() => { /* ignore */ });
+ };
+ }, []);
+
+ function tickStart() {
+ if (intervalRef.current) window.clearInterval(intervalRef.current);
+ intervalRef.current = window.setInterval(() => {
+ const now = Date.now();
+ const elapsed = Math.floor((now - startTimeRef.current - pauseAccumRef.current) / 1000);
+ setSeconds(elapsed);
+ }, 1000);
+ }
+ function tickStop() {
+ if (intervalRef.current) { window.clearInterval(intervalRef.current); intervalRef.current = null; }
+ }
+
+ async function start() {
+ if (state !== 'idle') return;
+ finalTextRef.current = '';
+ pauseAccumRef.current = 0;
+ setSeconds(0);
+ try {
+ const rec = new AudioRecorder();
+ await rec.start();
+ recorderRef.current = rec;
+ startTimeRef.current = Date.now();
+ tickStart();
+ setState('recording');
+ // Live preview via Web Speech (only if user enabled it in Settings).
+ if (isSpeechRecognitionEnabled()) {
+ const handle = createSpeechSession({
+ onFinal: (chunk) => {
+ const deduped = deduplicateFinal(chunk, finalTextRef.current);
+ finalTextRef.current += deduped;
+ onInterim?.(finalTextRef.current);
+ },
+ onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
+ onError: () => { /* swallow */ },
+ });
+ speechRef.current = handle;
+ handle?.start();
+ }
+ } catch {
+ onError?.('Microphone permission denied');
+ setState('idle');
+ }
+ }
+
+ function pause() {
+ if (state !== 'recording') return;
+ recorderRef.current?.pause();
+ speechRef.current?.stop();
+ pauseStartRef.current = Date.now();
+ tickStop();
+ setState('paused');
+ }
+ function resume() {
+ if (state !== 'paused') return;
+ recorderRef.current?.resume();
+ pauseAccumRef.current += Date.now() - pauseStartRef.current;
+ tickStart();
+ if (isSpeechRecognitionEnabled() && !speechRef.current) {
+ const handle = createSpeechSession({
+ onFinal: (chunk) => {
+ const deduped = deduplicateFinal(chunk, finalTextRef.current);
+ finalTextRef.current += deduped;
+ onInterim?.(finalTextRef.current);
+ },
+ onInterim: (interim) => onInterim?.(finalTextRef.current + interim),
+ });
+ speechRef.current = handle;
+ }
+ speechRef.current?.start();
+ setState('recording');
+ }
+
+ async function stop() {
+ if (state !== 'recording' && state !== 'paused') return;
+ const liveText = finalTextRef.current.trim();
+ speechRef.current?.stop();
+ speechRef.current = null;
+ tickStop();
+ const dur = seconds;
+ setState('transcribing');
+ try {
+ const blob = await recorderRef.current!.stop();
+ recorderRef.current = null;
+ if (!blob || blob.size === 0) {
+ // Recording produced nothing — fall back to live preview if any.
+ if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
+ setState('idle');
+ return;
+ }
+ // Server-side too-large guard mirrors the vanilla 24 MB cap.
+ if (blob.size > 24 * 1024 * 1024) {
+ onError?.('Recording too large for AI transcription — using live transcript');
+ if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
+ setState('idle');
+ return;
+ }
+ const result = await transcribeAudio(blob, module);
+ if (result.success && result.text) {
+ onTranscript(result.text, { provider: result.provider, durationSec: dur, appended: false });
+ } else if (result.noProvider) {
+ if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
+ else onError?.('No transcription API configured');
+ } else {
+ if (liveText) onTranscript(liveText, { durationSec: dur, appended: true });
+ else onError?.(result.error || 'Transcription failed');
+ }
+ } catch (e) {
+ onError?.((e as Error).message);
+ } finally {
+ setState('idle');
+ }
+ }
+
+ const mm = String(Math.floor(seconds / 60)).padStart(2, '0');
+ const ss = String(seconds % 60).padStart(2, '0');
+
+ return (
+
+ {state === 'idle' && (
+
+ )}
+ {(state === 'recording' || state === 'paused') && (
+ <>
+
+
+
+ ●
+ {state === 'paused' ? 'paused ' : ''}{mm}:{ss}
+
+ >
+ )}
+ {state === 'transcribing' && (
+
+ ⌛ Transcribing…
+
+ )}
+
+ );
+}
diff --git a/client/src/lib/encounter-persistence.ts b/client/src/lib/encounter-persistence.ts
new file mode 100644
index 0000000..875f293
--- /dev/null
+++ b/client/src/lib/encounter-persistence.ts
@@ -0,0 +1,124 @@
+// ============================================================
+// Encounter persistence — port of public/js/encounters.js save/load
+// flow. sessionStorage keys survive page refresh + sign-out so a
+// resumed session lands on the same DB row instead of creating a
+// duplicate. Optimistic-locking via expected_version preserved.
+// ============================================================
+
+export type EncType = 'encounter' | 'dictation' | 'hospital' | 'chart' | 'wellvisit' | 'sickvisit' | 'soap';
+
+const SAVED_KEY = (t: EncType) => '_savedEncId_' + t;
+const IDEMP_KEY = (t: EncType) => '_idempKey_' + t;
+
+export function getSavedEncId(t: EncType): number | null {
+ try {
+ const v = sessionStorage.getItem(SAVED_KEY(t));
+ return v ? Number(v) : null;
+ } catch { return null; }
+}
+export function setSavedEncId(t: EncType, id: number | null) {
+ try {
+ if (id == null) sessionStorage.removeItem(SAVED_KEY(t));
+ else sessionStorage.setItem(SAVED_KEY(t), String(id));
+ } catch { /* ignore */ }
+}
+
+export function getIdempotencyKey(t: EncType): string {
+ try {
+ const existing = sessionStorage.getItem(IDEMP_KEY(t));
+ if (existing) return existing;
+ } catch { /* ignore */ }
+ const fresh = (typeof crypto !== 'undefined' && crypto.randomUUID)
+ ? crypto.randomUUID()
+ : 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, (c) => {
+ const r = Math.random() * 16 | 0;
+ return (c === 'x' ? r : (r & 0x3 | 0x8)).toString(16);
+ });
+ try { sessionStorage.setItem(IDEMP_KEY(t), fresh); } catch { /* ignore */ }
+ return fresh;
+}
+export function resetIdempotencyKey(t: EncType) {
+ try { sessionStorage.removeItem(IDEMP_KEY(t)); } catch { /* ignore */ }
+}
+
+const _versions = new Map();
+
+export interface SaveEncounterInput {
+ type: EncType;
+ label: string;
+ transcript: string;
+ generatedNote: string;
+ partialData?: unknown; // serialized to JSON
+}
+export interface SaveEncounterResult {
+ id: number;
+ version: number;
+}
+
+export async function saveEncounter(input: SaveEncounterInput): Promise {
+ if (!input.label.trim()) throw new Error('Enter a patient label first');
+ const id = getSavedEncId(input.type);
+ const body: Record = {
+ label: input.label.trim(),
+ enc_type: input.type,
+ transcript: input.transcript,
+ generated_note: input.generatedNote,
+ partial_data: input.partialData != null ? JSON.stringify(input.partialData) : undefined,
+ idempotency_key: getIdempotencyKey(input.type),
+ };
+ if (id != null) {
+ body.id = id;
+ const expected = _versions.get(id);
+ if (expected != null) body.expected_version = expected;
+ }
+ const r = await fetch('/api/encounters/saved', {
+ method: 'POST',
+ credentials: 'include',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ const data = await r.json();
+ if (r.status === 409) throw new Error('Someone else edited this encounter. Reload to see the latest version.');
+ if (!data.success) throw new Error(data.error || 'Save failed');
+ if (data.id != null) {
+ setSavedEncId(input.type, data.id);
+ if (data.version != null) _versions.set(data.id, data.version);
+ }
+ return { id: data.id, version: data.version };
+}
+
+export interface LoadedEncounter {
+ id: number;
+ label: string;
+ enc_type: string;
+ transcript: string;
+ generated_note: string;
+ partial_data: string | null;
+ version?: number;
+}
+export async function loadEncounter(id: number): Promise {
+ const r = await fetch('/api/encounters/saved/' + id, { credentials: 'include' });
+ const data = await r.json();
+ if (!data.success) throw new Error(data.error || 'Load failed');
+ if (data.encounter && data.encounter.version != null) _versions.set(id, data.encounter.version);
+ return data.encounter as LoadedEncounter;
+}
+
+export interface SavedEncounterListEntry {
+ id: number;
+ label: string;
+ enc_type: EncType;
+ status?: string;
+ updated_at: string;
+ expires_at: string;
+}
+export async function listSavedEncounters(): Promise {
+ const r = await fetch('/api/encounters/saved', { credentials: 'include' });
+ const data = await r.json();
+ return (data.encounters || []) as SavedEncounterListEntry[];
+}
+
+export function clearTabState(t: EncType) {
+ setSavedEncId(t, null);
+ resetIdempotencyKey(t);
+}
diff --git a/client/src/lib/recorder.ts b/client/src/lib/recorder.ts
new file mode 100644
index 0000000..9dbfebb
--- /dev/null
+++ b/client/src/lib/recorder.ts
@@ -0,0 +1,59 @@
+// ============================================================
+// AudioRecorder — verbatim port of public/js/app.js:660-687.
+// Same constraints (mono, 16kHz, EC+NS), same opus 32kbps target.
+// Exposes the underlying mediaRecorder + stream so the React
+// Recorder component can pause/resume + restart on the same stream
+// (matches vanilla liveEncounter.js fallback behavior).
+// ============================================================
+
+export class AudioRecorder {
+ mediaRecorder: MediaRecorder | null = null;
+ chunks: Blob[] = [];
+ stream: MediaStream | null = null;
+
+ start(): Promise {
+ this.chunks = [];
+ return navigator.mediaDevices.getUserMedia({
+ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true },
+ }).then((stream) => {
+ this.stream = stream;
+ const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
+ this.mediaRecorder = new MediaRecorder(stream, { mimeType: mime, audioBitsPerSecond: 32000 });
+ this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
+ this.mediaRecorder.start(1000);
+ });
+ }
+
+ stop(): Promise {
+ return new Promise((resolve) => {
+ if (!this.mediaRecorder || this.mediaRecorder.state === 'inactive') { resolve(null); return; }
+ this.mediaRecorder.onstop = () => {
+ const blob = new Blob(this.chunks, { type: this.mediaRecorder!.mimeType });
+ if (this.stream) this.stream.getTracks().forEach((t) => t.stop());
+ resolve(blob);
+ };
+ this.mediaRecorder.stop();
+ });
+ }
+
+ pause() {
+ if (this.mediaRecorder && typeof this.mediaRecorder.pause === 'function' && this.mediaRecorder.state === 'recording') {
+ try { this.mediaRecorder.pause(); } catch { /* ignore */ }
+ }
+ }
+
+ resume() {
+ if (!this.mediaRecorder) return;
+ try {
+ if (typeof this.mediaRecorder.resume === 'function' && this.mediaRecorder.state === 'paused') {
+ this.mediaRecorder.resume();
+ } else if (this.mediaRecorder.state === 'inactive' && this.stream && this.stream.active) {
+ // Browser killed it — restart on the same stream (matches vanilla fallback).
+ const mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
+ this.mediaRecorder = new MediaRecorder(this.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
+ this.mediaRecorder.ondataavailable = (e) => { if (e.data.size > 0) this.chunks.push(e.data); };
+ this.mediaRecorder.start(1000);
+ }
+ } catch { /* ignore */ }
+ }
+}
diff --git a/client/src/lib/transcribe.ts b/client/src/lib/transcribe.ts
new file mode 100644
index 0000000..3558025
--- /dev/null
+++ b/client/src/lib/transcribe.ts
@@ -0,0 +1,99 @@
+// ============================================================
+// Transcribe + audio-backup helpers — port of public/js/app.js
+// transcribeAudio() / _serverTranscribe() / saveAudioBackup().
+//
+// Server-first via /api/transcribe (cookie auth — same-origin).
+// On failure, blob is saved to /api/audio-backups so the user can
+// retry from Settings → Audio Backups (or vanilla Audio Backups page).
+// IndexedDB fallback preserved from vanilla audioBackup.js.
+// ============================================================
+
+export interface TranscribeResult {
+ success: boolean;
+ text?: string;
+ provider?: string;
+ duration?: number;
+ noProvider?: boolean;
+ error?: string;
+}
+
+let transcribeAvailable: boolean | null = null;
+let transcribeProvider = 'none';
+
+export async function checkTranscribeStatus(): Promise {
+ try {
+ const r = await fetch('/api/transcribe/status', { credentials: 'include' });
+ const data = await r.json();
+ transcribeAvailable = !!data.available;
+ transcribeProvider = data.provider || 'none';
+ } catch {
+ transcribeAvailable = false;
+ }
+}
+
+export function isTranscribeAvailable(): boolean | null { return transcribeAvailable; }
+export function getTranscribeProvider(): string { return transcribeProvider; }
+
+export async function transcribeAudio(blob: Blob, module = 'encounter'): Promise {
+ if (transcribeAvailable === null) await checkTranscribeStatus();
+ if (transcribeAvailable === false) {
+ return { success: false, noProvider: true, error: 'No transcription API configured — using live transcript' };
+ }
+ const form = new FormData();
+ form.append('audio', blob, 'audio.webm');
+ try {
+ const r = await fetch('/api/transcribe', { method: 'POST', credentials: 'include', body: form });
+ const data: TranscribeResult = await r.json();
+ if (!data.success && blob.size > 0) {
+ // Best-effort: save the blob so the user can retry later.
+ saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
+ }
+ return data;
+ } catch (e) {
+ saveAudioBackup(blob, module + '-failed-transcription').catch(() => { /* ignore */ });
+ return { success: false, error: (e as Error).message };
+ }
+}
+
+// ── Audio backup (server-first, IndexedDB fallback) ──
+export async function saveAudioBackup(blob: Blob, module: string): Promise {
+ // Server first.
+ try {
+ const form = new FormData();
+ form.append('audio', blob, 'audio.webm');
+ form.append('module', module);
+ const r = await fetch('/api/audio-backups', { method: 'POST', credentials: 'include', body: form });
+ const data = await r.json();
+ if (data.success && data.id) return data.id;
+ } catch { /* fall through */ }
+ // IndexedDB fallback.
+ return saveToIndexedDB(blob, module);
+}
+
+const DB_NAME = 'PedScribeAudioBackup';
+const STORE = 'recordings';
+let _db: IDBDatabase | null = null;
+function openDB(): Promise {
+ if (_db) return Promise.resolve(_db);
+ return new Promise((resolve, reject) => {
+ const req = indexedDB.open(DB_NAME, 1);
+ req.onupgradeneeded = (e) => {
+ const db = (e.target as IDBOpenDBRequest).result;
+ if (!db.objectStoreNames.contains(STORE)) {
+ const store = db.createObjectStore(STORE, { keyPath: 'id', autoIncrement: true });
+ store.createIndex('timestamp', 'timestamp', { unique: false });
+ }
+ };
+ req.onsuccess = () => { _db = req.result; resolve(_db); };
+ req.onerror = () => reject(new Error('IndexedDB open failed'));
+ });
+}
+function saveToIndexedDB(blob: Blob, module: string): Promise {
+ return openDB().then((db) => new Promise((resolve, reject) => {
+ const tx = db.transaction(STORE, 'readwrite');
+ const store = tx.objectStore(STORE);
+ const req = store.add({ blob, module, timestamp: Date.now(), size: blob.size, mimeType: blob.type });
+ req.onsuccess = () => resolve(req.result as number);
+ req.onerror = () => reject(new Error('Failed to save audio backup'));
+ })).catch(() => null);
+}
diff --git a/client/src/lib/web-speech.ts b/client/src/lib/web-speech.ts
new file mode 100644
index 0000000..3d36f91
--- /dev/null
+++ b/client/src/lib/web-speech.ts
@@ -0,0 +1,117 @@
+// ============================================================
+// Web Speech API wrapper — verbatim port of
+// public/js/speechRecognition.js. Browser-side live transcription
+// for showing words as the user speaks. Falls back to no-op when
+// the API is unsupported. Privacy warning preserved.
+// ============================================================
+
+const STORAGE_ENABLED = 'ped_web_speech_enabled';
+
+interface SpeechRecognitionResult {
+ isFinal: boolean;
+ [index: number]: { transcript: string };
+}
+interface SpeechRecognitionResults {
+ length: number;
+ [index: number]: SpeechRecognitionResult;
+}
+interface SpeechRecognitionEvent {
+ resultIndex: number;
+ results: SpeechRecognitionResults;
+}
+interface SpeechRecognitionErrorEvent { error: string }
+interface SpeechRecognitionLike {
+ continuous: boolean;
+ interimResults: boolean;
+ lang: string;
+ maxAlternatives: number;
+ onresult: ((e: SpeechRecognitionEvent) => void) | null;
+ onerror: ((e: SpeechRecognitionErrorEvent) => void) | null;
+ onend: (() => void) | null;
+ onstart: (() => void) | null;
+ start(): void;
+ stop(): void;
+}
+type SRCtor = new () => SpeechRecognitionLike;
+
+function getCtor(): SRCtor | null {
+ if (typeof window === 'undefined') return null;
+ const w = window as unknown as { SpeechRecognition?: SRCtor; webkitSpeechRecognition?: SRCtor };
+ return (w.SpeechRecognition || w.webkitSpeechRecognition) || null;
+}
+
+export function isSpeechRecognitionSupported(): boolean {
+ return !!getCtor();
+}
+export function isSpeechRecognitionEnabled(): boolean {
+ try { return localStorage.getItem(STORAGE_ENABLED) === '1' || localStorage.getItem(STORAGE_ENABLED) === 'true'; }
+ catch { return false; }
+}
+export function setSpeechRecognitionEnabled(v: boolean) {
+ try { localStorage.setItem(STORAGE_ENABLED, v ? 'true' : 'false'); } catch { /* ignore */ }
+}
+
+export interface SpeechHandle {
+ start: () => void;
+ stop: () => void;
+}
+
+// Creates a continuous-listening session that calls handlers as words
+// come in. Mirrors the liveEncounter.js wiring (final + interim results).
+export function createSpeechSession(handlers: {
+ onFinal: (text: string) => void;
+ onInterim: (text: string) => void;
+ onError?: (err: string) => void;
+}): SpeechHandle | null {
+ const Ctor = getCtor();
+ if (!Ctor) return null;
+ let rec: SpeechRecognitionLike | null = null;
+ let active = false;
+
+ function build(): SpeechRecognitionLike {
+ const r = new (Ctor as SRCtor)();
+ r.continuous = true;
+ r.interimResults = true;
+ r.lang = 'en-US';
+ r.maxAlternatives = 1;
+ r.onresult = (e: SpeechRecognitionEvent) => {
+ let interim = '';
+ for (let i = e.resultIndex; i < e.results.length; i++) {
+ const t = e.results[i][0].transcript;
+ if (e.results[i].isFinal) handlers.onFinal(t + ' ');
+ else interim = t;
+ }
+ handlers.onInterim(interim);
+ };
+ r.onerror = (e: SpeechRecognitionErrorEvent) => {
+ if (e.error === 'no-speech' || e.error === 'aborted') return;
+ handlers.onError?.(e.error);
+ };
+ r.onend = () => { if (active) try { r.start(); } catch { /* ignore */ } };
+ return r;
+ }
+
+ return {
+ start: () => { active = true; rec = build(); try { rec.start(); } catch { /* ignore */ } },
+ stop: () => { active = false; if (rec) try { rec.stop(); } catch { /* ignore */ } },
+ };
+}
+
+// Verbatim port of deduplicateFinal from public/js/app.js:964-984.
+export function deduplicateFinal(newText: string, existingText: string): string {
+ if (!newText || !existingText) return newText;
+ const trimmed = newText.trim();
+ if (!trimmed) return '';
+ if (existingText.trimEnd().endsWith(trimmed)) return '';
+ const words = trimmed.split(/\s+/);
+ if (words.length >= 3) {
+ const tail = existingText.trimEnd().split(/\s+/).slice(-words.length).join(' ');
+ if (tail === trimmed) return '';
+ const half = Math.ceil(words.length / 2);
+ const firstHalf = words.slice(0, half).join(' ');
+ if (existingText.trimEnd().endsWith(firstHalf)) {
+ return words.slice(half).join(' ') + ' ';
+ }
+ }
+ return newText;
+}
diff --git a/client/src/pages/ChartReview.tsx b/client/src/pages/ChartReview.tsx
index 673c34e..a24783f 100644
--- a/client/src/pages/ChartReview.tsx
+++ b/client/src/pages/ChartReview.tsx
@@ -6,14 +6,17 @@ import { useState } from 'react';
import { useMutation } from '@tanstack/react-query';
import { api, ApiError } from '@/lib/api';
import type { ChartReviewOk } from '@/shared/types';
+import EncounterToolbar from '@/components/EncounterToolbar';
type ReviewType = 'outpatient' | 'subspecialty' | 'ed';
+const TYPE = 'chart' as const;
interface VisitInput { date: string; content: string; labs: string }
function emptyVisit(): VisitInput { return { date: '', content: '', labs: '' }; }
export default function ChartReview() {
+ const [label, setLabel] = useState('');
const [type, setType] = useState('outpatient');
const [patientAge, setPatientAge] = useState('');
const [patientGender, setPatientGender] = useState('');
@@ -56,6 +59,34 @@ export default function ChartReview() {
+ {
+ try {
+ const parsedVisits = enc.transcript ? JSON.parse(enc.transcript) as VisitInput[] : [emptyVisit()];
+ setVisits(parsedVisits.length ? parsedVisits : [emptyVisit()]);
+ } catch { setVisits([emptyVisit()]); }
+ setResult(enc.generated_note || null);
+ try {
+ const pd = enc.partial_data ? JSON.parse(enc.partial_data) : null;
+ if (pd?.type) setType(pd.type);
+ if (pd?.age) setPatientAge(pd.age);
+ if (pd?.gender) setPatientGender(pd.gender);
+ if (pd?.pmh) setPmh(pd.pmh);
+ if (pd?.additionalInstructions) setAdditionalInstructions(pd.additionalInstructions);
+ } catch { /* ignore */ }
+ setLabel(enc.label || '');
+ }}
+ onClear={() => {
+ setVisits([emptyVisit()]); setResult(null);
+ setType('outpatient'); setPatientAge(''); setPatientGender('');
+ setPmh(''); setAdditionalInstructions('');
+ }}
+ />
+