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
99 lines
4 KiB
TypeScript
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);
|
|
}
|