Export - Both stores already held the audio (server /audio-backups/:id/audio and the local IndexedDB record) but nothing exposed it, so a recording could not be taken out of the app. Each backup row now has a download, naming the file by its timestamp and using the extension actually recorded (webm, or m4a on iOS). A local record is only handed over to the account that owns it. Robustness - MediaRecorder had no onerror and nothing watched the audio track, so a recorder that failed, or a microphone claimed by another app, unplugged, or revoked, left the tab saying "recording" while capturing nothing. Both are now reported once, with the chunks captured so far kept, so stopping still returns the audio up to the failure. Deliberately not added: a wake lock. Stopping when the screen sleeps or the session ends is the intended behaviour — recording is meant to be deliberate, and nothing is left behind. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
430 lines
17 KiB
JavaScript
430 lines
17 KiB
JavaScript
// ============================================================
|
|
// AUDIO BACKUP — Server-side audio backup with local IndexedDB fallback
|
|
// Saves audio to server (gzip compressed in PostgreSQL), auto-deletes 24h.
|
|
// Falls back to IndexedDB if server save fails.
|
|
// ============================================================
|
|
|
|
var DB_NAME = 'PedScribeAudioBackup';
|
|
var STORE_NAME = 'recordings';
|
|
var DB_VERSION = 1;
|
|
var MAX_AGE_MS = 24 * 60 * 60 * 1000;
|
|
|
|
var _db = null;
|
|
var boundary = window.AccountBoundary;
|
|
|
|
function guardTransaction(tx, owner) {
|
|
if (!boundary.valid(owner)) { tx.abort(); throw boundary.error(); }
|
|
function abort() { try { tx.abort(); } catch (e) {} }
|
|
window.addEventListener('account-boundary', abort, { once: true });
|
|
function done() { window.removeEventListener('account-boundary', abort); }
|
|
tx.addEventListener('complete', done);
|
|
tx.addEventListener('abort', done);
|
|
}
|
|
|
|
|
|
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 — tries server first, falls back to IndexedDB
|
|
window.saveAudioBackup = function(blob, module) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve(null);
|
|
// Try server save first
|
|
return saveToServer(blob, module).then(function(serverId) {
|
|
if (!boundary.valid(owner)) return null;
|
|
if (serverId) {
|
|
window._lastAudioBackupId = 'server_' + serverId;
|
|
return serverId;
|
|
}
|
|
// Fallback to IndexedDB
|
|
return saveToIndexedDB(blob, module, owner);
|
|
}).catch(function() {
|
|
return saveToIndexedDB(blob, module, owner);
|
|
});
|
|
};
|
|
|
|
function saveToServer(blob, module) {
|
|
var formData = new FormData();
|
|
formData.append('audio', blob, 'audio.webm');
|
|
formData.append('module', module || 'encounter');
|
|
|
|
var headers = getAuthHeaders();
|
|
delete headers['Content-Type']; // FormData supplies its own boundary.
|
|
|
|
return fetch('/api/audio-backups', {
|
|
method: 'POST',
|
|
headers: headers,
|
|
credentials: 'same-origin',
|
|
body: formData
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) {
|
|
if (data.success) return data.id;
|
|
return null;
|
|
})
|
|
.catch(function() { return null; });
|
|
}
|
|
|
|
function saveToIndexedDB(blob, module, owner) {
|
|
return openDB().then(function(db) {
|
|
if (!boundary.valid(owner)) return null;
|
|
return new Promise(function(resolve, reject) {
|
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
|
guardTransaction(tx, owner);
|
|
tx.onabort = function() { resolve(null); };
|
|
var store = tx.objectStore(STORE_NAME);
|
|
var record = {
|
|
owner: owner,
|
|
blob: blob,
|
|
module: module || 'unknown',
|
|
timestamp: Date.now(),
|
|
size: blob.size,
|
|
mimeType: blob.type
|
|
};
|
|
var req = store.add(record);
|
|
tx.oncomplete = function() { resolve(boundary.valid(owner) ? req.result : null); };
|
|
req.onerror = function() { reject(new Error('Failed to save audio backup')); };
|
|
});
|
|
}).then(function(id) {
|
|
if (!id || !boundary.valid(owner)) return null;
|
|
window._lastAudioBackupId = 'local_' + id;
|
|
cleanupOldLocalBackups();
|
|
return id;
|
|
}).catch(function(err) {
|
|
console.warn('[AudioBackup] Save failed:', err.message);
|
|
return null;
|
|
});
|
|
}
|
|
|
|
// Delete a specific backup (server or local)
|
|
window.deleteAudioBackup = function(id) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve();
|
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
|
var serverId = id.replace('server_', '');
|
|
return fetch('/api/audio-backups/' + serverId, {
|
|
method: 'DELETE',
|
|
headers: getAuthHeaders()
|
|
}).then(function() {}).catch(function() {});
|
|
}
|
|
// Local IndexedDB delete
|
|
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : id;
|
|
return openDB().then(function(db) {
|
|
if (!boundary.valid(owner)) return;
|
|
return new Promise(function(resolve) {
|
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
|
guardTransaction(tx, owner);
|
|
var store = tx.objectStore(STORE_NAME);
|
|
var request = store.get(localId);
|
|
request.onsuccess = function() {
|
|
if (boundary.valid(owner) && request.result && request.result.owner === owner) store.delete(localId);
|
|
};
|
|
tx.onabort = function() { resolve(); };
|
|
tx.oncomplete = function() { resolve(); };
|
|
tx.onerror = function() { resolve(); };
|
|
});
|
|
}).catch(function() {});
|
|
};
|
|
|
|
// Get all backups (merged: server + local)
|
|
window.getAudioBackups = function() {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve([]);
|
|
var serverPromise = fetchServerBackups();
|
|
var localPromise = getLocalBackups();
|
|
|
|
return Promise.all([serverPromise, localPromise]).then(function(results) {
|
|
if (!boundary.valid(owner)) return [];
|
|
var server = results[0].map(function(b) {
|
|
return {
|
|
id: 'server_' + b.id,
|
|
module: b.module,
|
|
timestamp: new Date(b.created_at).getTime(),
|
|
size: b.size_bytes,
|
|
compressedSize: b.compressed_bytes,
|
|
source: 'server',
|
|
expiresAt: b.expires_at
|
|
};
|
|
});
|
|
var local = results[1].map(function(b) {
|
|
return {
|
|
id: 'local_' + b.id,
|
|
module: b.module,
|
|
timestamp: b.timestamp,
|
|
size: b.size,
|
|
source: 'local'
|
|
};
|
|
});
|
|
return server.concat(local).sort(function(a, b) { return b.timestamp - a.timestamp; });
|
|
});
|
|
};
|
|
|
|
function fetchServerBackups() {
|
|
var headers = getAuthHeaders();
|
|
return fetch('/api/audio-backups', {
|
|
headers: headers,
|
|
credentials: 'same-origin'
|
|
})
|
|
.then(function(r) { return r.json(); })
|
|
.then(function(data) { return data.success ? (data.backups || []) : []; })
|
|
.catch(function() { return []; });
|
|
}
|
|
|
|
function getLocalBackups() {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.resolve([]);
|
|
return openDB().then(function(db) {
|
|
return new Promise(function(resolve) {
|
|
var tx = db.transaction(STORE_NAME, 'readonly');
|
|
var req = tx.objectStore(STORE_NAME).getAll();
|
|
req.onsuccess = function() {
|
|
var records = (req.result || []).filter(function(r) {
|
|
return boundary.valid(owner) && r.owner === owner && (Date.now() - r.timestamp) < MAX_AGE_MS;
|
|
});
|
|
resolve(records);
|
|
};
|
|
req.onerror = function() { resolve([]); };
|
|
});
|
|
}).catch(function() { return []; });
|
|
}
|
|
|
|
// Retry transcription from backup
|
|
// A recording is significant clinical material, so it must be possible to
|
|
// take a copy out of the app — onto a phone's Files, a shared drive, an
|
|
// external recorder's card. Both stores can hand back the original audio;
|
|
// nothing exposed it before.
|
|
function backupBlob(id) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.reject(boundary.error());
|
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
|
return fetch('/api/audio-backups/' + id.replace('server_', '') + '/audio', { headers: getAuthHeaders(), credentials: 'same-origin' })
|
|
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); });
|
|
}
|
|
var localId = Number(String(id).replace('local_', ''));
|
|
return openDB().then(function(db) {
|
|
return new Promise(function(resolve, reject) {
|
|
var req = db.transaction(STORE_NAME, 'readonly').objectStore(STORE_NAME).get(localId);
|
|
req.onsuccess = function() {
|
|
var record = req.result;
|
|
if (!record || !boundary.valid(owner) || record.owner !== owner) { reject(new Error('Recording unavailable')); return; }
|
|
resolve(record.blob);
|
|
};
|
|
req.onerror = function() { reject(new Error('Recording unavailable')); };
|
|
});
|
|
});
|
|
}
|
|
|
|
window.downloadAudioBackup = function(id, stamp) {
|
|
return backupBlob(id).then(function(blob) {
|
|
var url = URL.createObjectURL(blob);
|
|
var link = document.createElement('a');
|
|
link.href = url;
|
|
// Extension follows the recorded type: webm on most browsers, mp4 on iOS.
|
|
var ext = (blob.type || '').indexOf('mp4') !== -1 ? 'm4a' : (blob.type || '').indexOf('ogg') !== -1 ? 'ogg' : 'webm';
|
|
link.download = 'recording-' + (stamp || Date.now()) + '.' + ext;
|
|
document.body.appendChild(link);
|
|
link.click();
|
|
document.body.removeChild(link);
|
|
setTimeout(function() { URL.revokeObjectURL(url); }, 30000);
|
|
showToast('Recording downloaded', 'success');
|
|
}).catch(function(err) {
|
|
showToast(err.message || 'Could not download the recording', 'error');
|
|
});
|
|
};
|
|
|
|
window.retryAudioBackup = function(id) {
|
|
var owner = boundary.capture();
|
|
if (!owner) return Promise.reject(boundary.error());
|
|
if (typeof id === 'string' && id.startsWith('server_')) {
|
|
var serverId = id.replace('server_', '');
|
|
showLoading('Downloading and re-transcribing...');
|
|
return fetch('/api/audio-backups/' + serverId + '/audio', {
|
|
headers: getAuthHeaders()
|
|
})
|
|
.then(function(r) { if (!r.ok) throw new Error('Download failed'); return r.blob(); })
|
|
.then(function(blob) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
window._lastAudioBackupId = id;
|
|
return transcribeAudio(blob).then(function(data) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
hideLoading();
|
|
if (data.success) {
|
|
showToast('Backup transcribed!', 'success');
|
|
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;
|
|
});
|
|
})
|
|
.catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); });
|
|
}
|
|
|
|
// Local IndexedDB retry
|
|
var localId = typeof id === 'string' ? parseInt(id.replace('local_', '')) : 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(localId);
|
|
req.onsuccess = function() {
|
|
if (!boundary.valid(owner) || !req.result || req.result.owner !== owner) { reject(new Error('Backup not found')); return; }
|
|
resolve(req.result);
|
|
};
|
|
req.onerror = function() { reject(new Error('Failed to read backup')); };
|
|
});
|
|
}).then(function(record) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
showLoading('Re-transcribing audio backup...');
|
|
window._lastAudioBackupId = id;
|
|
return transcribeAudio(record.blob).then(function(data) {
|
|
if (!boundary.valid(owner)) throw boundary.error();
|
|
hideLoading();
|
|
if (data.success) {
|
|
showToast('Backup transcribed!', 'success');
|
|
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;
|
|
});
|
|
});
|
|
};
|
|
|
|
function cleanupOldLocalBackups() {
|
|
openDB().then(function(db) {
|
|
var tx = db.transaction(STORE_NAME, 'readwrite');
|
|
var store = tx.objectStore(STORE_NAME);
|
|
var cutoff = Date.now() - MAX_AGE_MS;
|
|
var req = store.openCursor();
|
|
req.onsuccess = function(e) {
|
|
var cursor = e.target.result;
|
|
if (cursor) {
|
|
if (cursor.value.timestamp <= cutoff) 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;
|
|
var owner = boundary.capture();
|
|
getAudioBackups().then(function(backups) {
|
|
if (!boundary.valid(owner)) return;
|
|
container.textContent = '';
|
|
if (backups.length === 0) {
|
|
var empty = document.createElement('p');
|
|
empty.style.color = 'var(--g400)';
|
|
empty.style.fontSize = '13px';
|
|
empty.textContent = 'No audio backups. Recordings are saved automatically to the server and kept for 24 hours.';
|
|
container.appendChild(empty);
|
|
return;
|
|
}
|
|
backups.forEach(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';
|
|
var compInfo = b.compressedSize ? ' (' + Math.round(b.compressedSize / 1024) + ' KB compressed)' : '';
|
|
var row = document.createElement('div');
|
|
row.className = 'saved-enc-item';
|
|
row.style.padding = '8px 12px';
|
|
|
|
var body = document.createElement('div');
|
|
body.style.flex = '1';
|
|
|
|
var title = document.createElement('div');
|
|
title.style.fontWeight = '600';
|
|
title.style.fontSize = '13px';
|
|
title.style.display = 'flex';
|
|
title.style.alignItems = 'center';
|
|
title.style.gap = '6px';
|
|
title.appendChild(document.createTextNode((b.module || '') + ' recording '));
|
|
|
|
var sourceTag = document.createElement('span');
|
|
sourceTag.style.fontSize = '10px';
|
|
sourceTag.style.padding = '1px 5px';
|
|
sourceTag.style.borderRadius = '3px';
|
|
sourceTag.style.background = b.source === 'server' ? 'var(--blue-light)' : 'var(--g100)';
|
|
sourceTag.style.color = b.source === 'server' ? 'var(--blue)' : 'var(--g500)';
|
|
sourceTag.textContent = b.source === 'server' ? 'server' : 'local';
|
|
title.appendChild(sourceTag);
|
|
|
|
var meta = document.createElement('div');
|
|
meta.style.fontSize = '11px';
|
|
meta.style.color = 'var(--g500)';
|
|
meta.textContent = date.toLocaleString() + ' · ' + sizeKb + ' KB' + compInfo + ' · ' + ageStr;
|
|
|
|
body.appendChild(title);
|
|
body.appendChild(meta);
|
|
|
|
var retryBtn = document.createElement('button');
|
|
retryBtn.className = 'btn-sm btn-primary audio-backup-retry';
|
|
retryBtn.dataset.id = b.id;
|
|
var retryIcon = document.createElement('i');
|
|
retryIcon.className = 'fas fa-rotate-right';
|
|
retryBtn.appendChild(retryIcon);
|
|
retryBtn.appendChild(document.createTextNode(' Retry'));
|
|
|
|
var downloadBtn = document.createElement('button');
|
|
downloadBtn.className = 'btn-sm btn-ghost audio-backup-download';
|
|
downloadBtn.dataset.id = b.id;
|
|
downloadBtn.dataset.stamp = String(b.timestamp);
|
|
downloadBtn.title = 'Download this recording';
|
|
var downloadIcon = document.createElement('i');
|
|
downloadIcon.className = 'fas fa-download';
|
|
downloadBtn.appendChild(downloadIcon);
|
|
|
|
var deleteBtn = document.createElement('button');
|
|
deleteBtn.className = 'btn-sm btn-ghost audio-backup-delete';
|
|
deleteBtn.dataset.id = b.id;
|
|
deleteBtn.style.color = 'var(--red)';
|
|
var deleteIcon = document.createElement('i');
|
|
deleteIcon.className = 'fas fa-trash';
|
|
deleteBtn.appendChild(deleteIcon);
|
|
|
|
row.appendChild(body);
|
|
row.appendChild(retryBtn);
|
|
row.appendChild(downloadBtn);
|
|
row.appendChild(deleteBtn);
|
|
container.appendChild(row);
|
|
});
|
|
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); });
|
|
});
|
|
container.querySelectorAll('.audio-backup-download').forEach(function(btn) {
|
|
btn.addEventListener('click', function() { downloadAudioBackup(btn.dataset.id, btn.dataset.stamp); });
|
|
});
|
|
container.querySelectorAll('.audio-backup-delete').forEach(function(btn) {
|
|
btn.addEventListener('click', function() {
|
|
deleteAudioBackup(btn.dataset.id).then(function() {
|
|
showToast('Backup deleted', 'info');
|
|
renderAudioBackups();
|
|
});
|
|
});
|
|
});
|
|
});
|
|
};
|
|
|
|
cleanupOldLocalBackups();
|