pediatric-ai-scribe-v3/client/src/lib/transcribe.ts
Daniel cd1f762f34 feat(notes): restore audio recording + save/load across all 7 note pages
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_<type> + _idempKey_<type>
    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
2026-04-24 05:24:09 +02:00

99 lines
4 KiB
TypeScript

// ============================================================
// 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<void> {
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<TranscribeResult> {
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<number | null> {
// 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<IDBDatabase> {
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<number | null> {
return openDB().then((db) => new Promise<number>((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);
}