pediatric-ai-scribe-v3/public/js/audioBackup.js
Daniel Onyejesi 5f066bb760 v9: Major feature update — audio backup, SOAP save, Dragon memory, S3 docs, CI/CD, APK
Phase 1 — Critical Fixes:
- Fix SOAP instructions not clearing on Clear button
- Show transcription provider (AWS/OpenAI) in UI toast
- Fix silent transcription failures in dictation and SOAP modules
- Add IndexedDB audio backup system (24hr retention, retry from Settings)
- Prevent duplicate encounter saves with idempotency keys
- Add Save/Load/New bar to SOAP note generator

Phase 2 — Features:
- Dragon-like AI memory: auto-track user corrections, inject into prompts
- Per-section template categories (SOAP, HPI, well visit, sick visit)
- Bigger textarea for SOAP instructions
- S3 document upload/management (AWS S3, Backblaze B2, MinIO compatible)
- Faster transcription via lower bitrate recording (16kbps opus)

Phase 3 — APK & CI/CD:
- GitHub Actions: Docker build+push on version tags
- GitHub Actions: TWA APK build for Obtainium auto-updates
- Android TWA project with foreground service for background recording
- Enhanced PWA manifest with shortcuts and maskable icons
2026-03-28 21:08:32 +00:00

184 lines
6.9 KiB
JavaScript

// ============================================================
// AUDIO BACKUP — IndexedDB-based audio recording backup
// Saves audio blobs locally so failed transcriptions can be retried
// ============================================================
(function() {
var DB_NAME = 'PedScribeAudioBackup';
var STORE_NAME = 'recordings';
var DB_VERSION = 1;
var MAX_AGE_MS = 24 * 60 * 60 * 1000; // 24 hours
var _db = null;
function openDB() {
if (_db) return Promise.resolve(_db);
return new Promise(function(resolve, reject) {
var request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = function(e) {
var db = e.target.result;
if (!db.objectStoreNames.contains(STORE_NAME)) {
var store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
store.createIndex('timestamp', 'timestamp', { unique: false });
}
};
request.onsuccess = function(e) { _db = e.target.result; resolve(_db); };
request.onerror = function() { reject(new Error('IndexedDB open failed')); };
});
}
// Save audio blob with metadata
window.saveAudioBackup = function(blob, module) {
return openDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(STORE_NAME, 'readwrite');
var store = tx.objectStore(STORE_NAME);
var record = {
blob: blob,
module: module || 'unknown',
timestamp: Date.now(),
size: blob.size,
mimeType: blob.type
};
var req = store.add(record);
req.onsuccess = function() { resolve(req.result); }; // returns ID
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
});
}).then(function(id) {
window._lastAudioBackupId = id;
cleanupOldBackups();
return id;
}).catch(function(err) {
console.warn('[AudioBackup] Save failed:', err.message);
return null;
});
};
// Delete a specific backup
window.deleteAudioBackup = function(id) {
return openDB().then(function(db) {
return new Promise(function(resolve) {
var tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = function() { resolve(); };
tx.onerror = function() { resolve(); };
});
}).catch(function() {});
};
// Get all backups (for UI listing)
window.getAudioBackups = function() {
return openDB().then(function(db) {
return new Promise(function(resolve) {
var tx = db.transaction(STORE_NAME, 'readonly');
var store = tx.objectStore(STORE_NAME);
var req = store.getAll();
req.onsuccess = function() {
var records = (req.result || []).filter(function(r) {
return (Date.now() - r.timestamp) < MAX_AGE_MS;
});
resolve(records);
};
req.onerror = function() { resolve([]); };
});
}).catch(function() { return []; });
};
// Retry transcription from backup
window.retryAudioBackup = function(id) {
return openDB().then(function(db) {
return new Promise(function(resolve, reject) {
var tx = db.transaction(STORE_NAME, 'readonly');
var req = tx.objectStore(STORE_NAME).get(id);
req.onsuccess = function() {
if (!req.result) { reject(new Error('Backup not found')); return; }
resolve(req.result);
};
req.onerror = function() { reject(new Error('Failed to read backup')); };
});
}).then(function(record) {
showLoading('Re-transcribing audio backup...');
window._lastAudioBackupId = id;
return transcribeAudio(record.blob).then(function(data) {
hideLoading();
if (data.success) {
showToast('Backup transcribed successfully!', 'success');
// Copy to clipboard
if (navigator.clipboard && navigator.clipboard.writeText) {
navigator.clipboard.writeText(data.text);
showToast('Transcript copied to clipboard', 'info');
}
} else {
showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
}
return data;
});
});
};
// Clean up old backups
function cleanupOldBackups() {
openDB().then(function(db) {
var tx = db.transaction(STORE_NAME, 'readwrite');
var store = tx.objectStore(STORE_NAME);
var index = store.index('timestamp');
var cutoff = Date.now() - MAX_AGE_MS;
var range = IDBKeyRange.upperBound(cutoff);
var req = index.openCursor(range);
req.onsuccess = function(e) {
var cursor = e.target.result;
if (cursor) {
cursor.delete();
cursor.continue();
}
};
}).catch(function() {});
}
// Render audio backups list in settings
window.renderAudioBackups = function() {
var container = document.getElementById('audio-backups-list');
if (!container) return;
getAudioBackups().then(function(backups) {
if (backups.length === 0) {
container.innerHTML = '<p style="color:var(--g400);font-size:13px;">No audio backups. Recordings are saved here automatically and kept for 24 hours.</p>';
return;
}
container.innerHTML = backups.map(function(b) {
var date = new Date(b.timestamp);
var sizeKb = Math.round(b.size / 1024);
var age = Math.round((Date.now() - b.timestamp) / 60000);
var ageStr = age < 60 ? age + 'm ago' : Math.round(age / 60) + 'h ago';
return '<div class="saved-enc-item" style="padding:8px 12px;">' +
'<div style="flex:1;">' +
'<div style="font-weight:600;font-size:13px;">' + esc(b.module) + ' recording</div>' +
'<div style="font-size:11px;color:var(--g500);">' + date.toLocaleString() + ' · ' + sizeKb + ' KB · ' + ageStr + '</div>' +
'</div>' +
'<button class="btn-sm btn-primary audio-backup-retry" data-id="' + b.id + '"><i class="fas fa-rotate-right"></i> Retry</button>' +
'<button class="btn-sm btn-ghost audio-backup-delete" data-id="' + b.id + '" style="color:var(--red);"><i class="fas fa-trash"></i></button>' +
'</div>';
}).join('');
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
btn.addEventListener('click', function() { retryAudioBackup(parseInt(btn.dataset.id)); });
});
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
btn.addEventListener('click', function() {
deleteAudioBackup(parseInt(btn.dataset.id)).then(function() {
showToast('Backup deleted', 'info');
renderAudioBackups();
});
});
});
});
};
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
// Clean up on load
cleanupOldBackups();
console.log('Audio backup system loaded');
})();