diff --git a/public/js/app.js b/public/js/app.js index cb8741f8..b2f59b5d 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -976,9 +976,52 @@ function createTimer(el) { } // Audio Recorder +// The screen going to sleep suspends a recording, so hold a wake lock for as +// long as one is running. The browser drops the lock whenever the page is +// hidden, so it has to be taken again when the page comes back; without that, +// one glance away ends the lock for the rest of the session. The native app +// keeps its own lock through nativeKeepAwake(). +var _wakeLock = null; +var _wakeLockHolders = 0; + +function _acquireWakeLock() { + if (!_wakeLockHolders || _wakeLock) return Promise.resolve(null); + if (!navigator.wakeLock || typeof navigator.wakeLock.request !== 'function') return Promise.resolve(null); + if (document.visibilityState !== 'visible') return Promise.resolve(null); // the request would be rejected + return navigator.wakeLock.request('screen').then(function(lock) { + _wakeLock = lock; + lock.addEventListener('release', function() { _wakeLock = null; }); + return lock; + }).catch(function() { return null; }); // denied or unsupported: recording continues regardless +} + +function holdWakeLock() { _wakeLockHolders++; return _acquireWakeLock(); } + +function releaseWakeLock() { + _wakeLockHolders = Math.max(0, _wakeLockHolders - 1); + if (_wakeLockHolders > 0 || !_wakeLock) return; + var lock = _wakeLock; + _wakeLock = null; + try { lock.release(); } catch (e) {} +} + +document.addEventListener('visibilitychange', function() { + if (document.visibilityState === 'visible') _acquireWakeLock(); +}); +// Signing out must not leave the screen pinned awake for a recording that is +// no longer anyone's. +window.addEventListener('account-boundary', function() { + _wakeLockHolders = 0; + if (_wakeLock) { try { _wakeLock.release(); } catch (e) {} _wakeLock = null; } +}); + function AudioRecorder() { this.mediaRecorder = null; this.chunks = []; this.stream = null; } AudioRecorder.prototype.start = function() { - var self = this; self.chunks = []; + var self = this; + // Idempotent: a second start on a running recorder would replace the + // MediaRecorder and silently drop everything captured so far. + if (self.mediaRecorder && self.mediaRecorder.state === 'recording') return Promise.resolve(); + self.chunks = []; return navigator.mediaDevices.getUserMedia({ audio: { channelCount: 1, sampleRate: 16000, echoCancellation: true, noiseSuppression: true } }) .then(function(stream) { self.stream = stream; @@ -1009,6 +1052,8 @@ AudioRecorder.prototype.start = function() { }); }); self.mediaRecorder.start(1000); + self.heldWakeLock = true; + holdWakeLock(); }); }; // Announced once per recorder, so a caller that is listening can stop cleanly @@ -1027,6 +1072,7 @@ AudioRecorder.prototype.notifyFailure = function() { AudioRecorder.prototype.stop = function() { var self = this; + if (self.heldWakeLock) { self.heldWakeLock = false; releaseWakeLock(); } return new Promise(function(resolve) { if (!self.mediaRecorder || self.mediaRecorder.state === 'inactive') { resolve(null); return; } self.mediaRecorder.onstop = function() { diff --git a/public/js/liveEncounter.js b/public/js/liveEncounter.js index f1650bbc..a38a1880 100644 --- a/public/js/liveEncounter.js +++ b/public/js/liveEncounter.js @@ -114,6 +114,25 @@ var _liveEncounterInited = false; } }); + // A recording that ends by itself — the recorder erred, or the microphone was + // taken by another app, unplugged or revoked — takes the same path as pressing + // Stop, so whatever was captured is transcribed and stored rather than sitting + // in a tab that still says "recording". + document.addEventListener('audio-recorder-failed', function() { + if (!isRecording) return; + recordBtn.style.display = ''; + recordBtn.click(); + }); + + // Signing out mid-recording stops it. Nothing is sent: the session that owned + // the audio is gone. + window.addEventListener('account-boundary', function() { + if (!isRecording) return; + isRecording = false; + try { recorder.stop(); } catch (e) {} + try { if (recognition) recognition.stop(); } catch (e) {} + }); + // Dedicated stop button (always visible during recording) if (stopBtn) { stopBtn.addEventListener('click', function() { diff --git a/src/db/database.js b/src/db/database.js index c889a0b0..2bd70779 100644 --- a/src/db/database.js +++ b/src/db/database.js @@ -312,11 +312,13 @@ async function initDatabase() { size_bytes INTEGER DEFAULT 0, compressed_bytes INTEGER DEFAULT 0, audio_data BYTEA NOT NULL, + storage_key TEXT, created_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ DEFAULT NOW() + INTERVAL '24 hours' ); CREATE INDEX IF NOT EXISTS idx_audio_backups_user ON audio_backups(user_id); CREATE INDEX IF NOT EXISTS idx_audio_backups_expires ON audio_backups(expires_at); + ALTER TABLE audio_backups ADD COLUMN IF NOT EXISTS storage_key TEXT; `); } catch(e) {} // User documents table for S3 storage @@ -477,8 +479,16 @@ async function cleanupExpired() { if (enc.rowCount > 0) console.log('[DB] Cleaned up ' + enc.rowCount + ' expired encounters'); } catch(e) { console.error('[DB] Encounter cleanup error:', e.message); } try { - var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW()'); - if (audio.rowCount > 0) console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups'); + // RETURNING first: a row deleted without its object would leave the audio + // sitting in the bucket past its 24 hours with nothing left pointing at it. + var audio = await pool.query('DELETE FROM audio_backups WHERE expires_at < NOW() RETURNING storage_key'); + if (audio.rowCount > 0) { + var store = require('../utils/audioBackupStore'); + for (var i = 0; i < audio.rows.length; i++) { + if (audio.rows[i].storage_key) await store.removeObject(audio.rows[i].storage_key); + } + console.log('[DB] Cleaned up ' + audio.rowCount + ' expired audio backups'); + } } catch(e) { /* table may not exist yet */ } try { var sess = await pool.query("DELETE FROM user_sessions WHERE created_at < NOW() - INTERVAL '7 days'"); diff --git a/src/routes/audioBackups.js b/src/routes/audioBackups.js index 3c0c1bb4..df1e9a84 100644 --- a/src/routes/audioBackups.js +++ b/src/routes/audioBackups.js @@ -13,6 +13,7 @@ var multer = require('multer'); var db = require('../db/database'); var { authMiddleware } = require('../middleware/auth'); var cryptoUtil = require('../utils/crypto'); +var audioStore = require('../utils/audioBackupStore'); var { serverError } = require('../utils/errors'); // 25MB upload limit (same as transcribe). DiskStorage so 10 concurrent @@ -54,24 +55,11 @@ router.post('/audio-backups', upload.single('audio'), async function(req, res) { // anyway. For 25MB max this is a single, transient allocation per // request instead of a persistent 25MB Buffer pinned by multer. var raw = await fs.promises.readFile(tmpPath); + var saved = await audioStore.save(req.user.id, module, raw, req.file.mimetype || 'audio/webm'); + var ratio = originalSize > 0 ? Math.round((1 - saved.compressedSize / originalSize) * 100) : 0; + console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB -> ' + (saved.compressedSize / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + saved.id + ' user=' + req.user.id + ' via ' + saved.storage); - // Gzip compress, then AES-256-GCM encrypt the audio data. - var compressed = await new Promise(function(resolve, reject) { - zlib.gzip(raw, { level: 6 }, function(err, result) { - if (err) reject(err); else resolve(result); - }); - }); - var stored = cryptoUtil.encryptBuffer(compressed); - - var result = await db.run( - 'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)', - [req.user.id, module, req.file.mimetype || 'audio/webm', originalSize, stored.length, stored] - ); - - var ratio = originalSize > 0 ? Math.round((1 - compressed.length / originalSize) * 100) : 0; - console.log('[AudioBackup] Saved ' + (originalSize / 1024).toFixed(0) + 'KB → ' + (compressed.length / 1024).toFixed(0) + 'KB (' + ratio + '% compression) id=' + result.lastInsertRowid + ' user=' + req.user.id); - - res.json({ success: true, id: result.lastInsertRowid, originalSize: originalSize, compressedSize: compressed.length }); + res.json({ success: true, id: saved.id, originalSize: originalSize, compressedSize: saved.compressedSize }); } catch (e) { return serverError(res, 'AudioBackup save', e, 'Could not save audio backup'); } finally { @@ -93,35 +81,26 @@ router.get('/audio-backups', async function(req, res) { // ── GET download audio backup (decompressed) ───────────────────────────── router.get('/audio-backups/:id/audio', async function(req, res) { try { - var row = await db.get( - 'SELECT audio_data, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()', - [req.params.id, req.user.id] - ); - if (!row) return res.status(404).json({ error: 'Backup not found or expired' }); + var found = await audioStore.load(req.params.id, req.user.id); + if (!found) return res.status(404).json({ error: 'Backup not found or expired' }); - // Decrypt (if encrypted row) then gunzip. Legacy rows are passed through. - var ciphertext = row.audio_data; - var gzipped = cryptoUtil.isEncryptedBuffer(ciphertext) ? cryptoUtil.decryptBuffer(ciphertext) : ciphertext; - var decompressed = await new Promise(function(resolve, reject) { - zlib.gunzip(gzipped, function(err, result) { - if (err) reject(err); else resolve(result); - }); - }); - - res.setHeader('Content-Type', row.mime_type || 'audio/webm'); - res.setHeader('Content-Length', decompressed.length); - res.send(decompressed); + res.setHeader('Content-Type', found.mimeType || 'audio/webm'); + res.setHeader('Content-Length', found.buffer.length); + res.send(found.buffer); } catch (e) { return serverError(res, 'AudioBackup read', e, 'Could not retrieve audio backup'); } }); // ── DELETE audio backup ────────────────────────────────────────────────── router.delete('/audio-backups/:id', async function(req, res) { try { + // Look the key up before the row goes, or the object is orphaned. + var doomed = await db.get('SELECT storage_key FROM audio_backups WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id]); var result = await db.run( 'DELETE FROM audio_backups WHERE id = $1 AND user_id = $2', [req.params.id, req.user.id] ); if (result.changes === 0) return res.status(404).json({ error: 'Not found' }); + if (doomed && doomed.storage_key) await audioStore.removeObject(doomed.storage_key); res.json({ success: true }); } catch (e) { return serverError(res, 'AudioBackup delete', e, 'Could not delete backup'); } }); diff --git a/src/routes/transcribe.js b/src/routes/transcribe.js index f9708ec1..a2b4c387 100644 --- a/src/routes/transcribe.js +++ b/src/routes/transcribe.js @@ -50,6 +50,19 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var mimeType = req.file.mimetype || 'audio/webm'; var ext = mimeType.split('/')[1] || 'webm'; + // Every recording is kept for 24 hours, not only the ones that fail. The + // audio is already here, so this costs no extra upload — and it means a + // recording survives even when the transcription succeeds and the note is + // later found wanting. A storage failure must never lose the transcription + // the clinician is waiting for, so it is logged, not thrown. + var backupId = null; + try { + var backup = await require('../utils/audioBackupStore').save(req.user.id, req.body.module || 'recording', req.file.buffer, mimeType); + backupId = backup.id; + } catch (backupErr) { + console.warn('[Transcribe] backup failed (transcription continues):', backupErr.message); + } + var file = new File([req.file.buffer], 'audio.' + ext, { type: mimeType }); var form = new FormData(); form.append('file', file); @@ -66,7 +79,7 @@ router.post('/transcribe', authMiddleware, upload.single('audio'), async (req, r var text = (data && data.text) ? String(data.text).trim() : ''; console.log('[Transcribe] LiteLLM/' + sttModel + ' done in ' + (Date.now() - startTime) + 'ms'); logger.audit(req.user.id, 'transcribe', 'Transcribed audio via litellm', req, { category: 'clinical' }); - return res.json({ success: true, text: text, provider: 'litellm/' + sttModel, duration: Date.now() - startTime }); + return res.json({ success: true, text: text, provider: 'litellm/' + sttModel, duration: Date.now() - startTime, backupId: backupId }); } catch (err) { var detail = err.response && err.response.data diff --git a/src/utils/audioBackupStore.js b/src/utils/audioBackupStore.js new file mode 100644 index 00000000..f08095a6 --- /dev/null +++ b/src/utils/audioBackupStore.js @@ -0,0 +1,145 @@ +// ============================================================ +// AUDIO BACKUP STORE +// One place that knows how a recording is kept, so /api/transcribe and +// /api/audio-backups cannot drift apart. A recording is significant clinical +// material: every one is kept for 24 hours, whether its transcription +// succeeded or not, and it is always compressed and encrypted at rest. +// +// Payload goes to object storage when AUDIO_BACKUPS_S3_* is configured, and +// to Postgres otherwise. Metadata (owner, module, sizes, expiry) always lives +// in Postgres, so listing, ownership and expiry work the same either way. +// ============================================================ + +var fs = require('fs'); +var zlib = require('zlib'); +var db = require('../db/database'); +var cryptoUtil = require('./crypto'); + +var RETENTION_HOURS = 24; + +function gzip(buffer) { + return new Promise(function(resolve, reject) { + zlib.gzip(buffer, { level: 6 }, function(err, result) { if (err) reject(err); else resolve(result); }); + }); +} +function gunzip(buffer) { + return new Promise(function(resolve, reject) { + zlib.gunzip(buffer, function(err, result) { if (err) reject(err); else resolve(result); }); + }); +} + +// Credentials come from files, as the rest of the stack does, so they are never +// environment strings in a process listing. +function readSecret(value, file) { + if (file) return fs.readFileSync(file, 'utf8').trim(); + return value || ''; +} + +var _client = null; +function objectStore(env) { + env = env || process.env; + if (!env.AUDIO_BACKUPS_S3_ENDPOINT || !env.AUDIO_BACKUPS_S3_BUCKET) return null; + if (_client) return _client; + var { S3Client } = require('@aws-sdk/client-s3'); + _client = { + Bucket: env.AUDIO_BACKUPS_S3_BUCKET, + client: new S3Client({ + endpoint: env.AUDIO_BACKUPS_S3_ENDPOINT, + region: env.AUDIO_BACKUPS_S3_REGION || 'us-east-1', + forcePathStyle: true, + credentials: { + accessKeyId: readSecret(env.AUDIO_BACKUPS_S3_ACCESS_KEY, env.AUDIO_BACKUPS_S3_ACCESS_KEY_FILE), + secretAccessKey: readSecret(env.AUDIO_BACKUPS_S3_SECRET_KEY, env.AUDIO_BACKUPS_S3_SECRET_KEY_FILE) + }, + maxAttempts: 2, + requestHandler: { connectionTimeout: 3000, requestTimeout: 20000 } + }) + }; + return _client; +} + +function isObjectStoreConfigured(env) { return !!objectStore(env); } + +// Keyed by owner so one user's recordings can never be addressed by another, +// even if an id leaks. +function objectKey(userId, stamp) { + return 'recordings/' + userId + '/' + stamp + '-' + Math.random().toString(36).slice(2, 10); +} + +// Returns the new row id. `module` records what produced it, and doubles as the +// marker for a recording whose transcription failed. +async function save(userId, module, buffer, mimeType) { + if (!buffer || !buffer.length) throw new Error('No audio to store'); + var compressed = await gzip(buffer); + var stored = cryptoUtil.encryptBuffer(compressed); + + var store = objectStore(); + if (store) { + var { PutObjectCommand } = require('@aws-sdk/client-s3'); + var key = objectKey(userId, Date.now()); + await store.client.send(new PutObjectCommand({ + Bucket: store.Bucket, Key: key, Body: stored, ContentType: 'application/octet-stream' + })); + var placed = await db.run( + 'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data, storage_key) VALUES ($1,$2,$3,$4,$5,$6,$7)', + [userId, module, mimeType || 'audio/webm', buffer.length, stored.length, Buffer.alloc(0), key] + ); + return { id: placed.lastInsertRowid, originalSize: buffer.length, compressedSize: compressed.length, storage: 'object' }; + } + + var row = await db.run( + 'INSERT INTO audio_backups (user_id, module, mime_type, size_bytes, compressed_bytes, audio_data) VALUES ($1,$2,$3,$4,$5,$6)', + [userId, module, mimeType || 'audio/webm', buffer.length, stored.length, stored] + ); + return { id: row.lastInsertRowid, originalSize: buffer.length, compressedSize: compressed.length, storage: 'database' }; +} + +// Ownership and expiry are enforced in the query, so an expired or borrowed id +// reads as missing rather than as someone else's recording. +async function load(id, userId) { + var row = await db.get( + 'SELECT audio_data, storage_key, mime_type, size_bytes FROM audio_backups WHERE id = $1 AND user_id = $2 AND expires_at > NOW()', + [id, userId] + ); + if (!row) return null; + var stored = row.audio_data; + if (row.storage_key) { + var store = objectStore(); + if (!store) throw new Error('Recording is in object storage, which is not configured'); + var { GetObjectCommand } = require('@aws-sdk/client-s3'); + var response = await store.client.send(new GetObjectCommand({ Bucket: store.Bucket, Key: row.storage_key })); + var chunks = []; + for await (var chunk of response.Body) chunks.push(chunk); + stored = Buffer.concat(chunks); + } + // Rows written before encryption was added hold plain gzip; pass those + // through rather than failing to decrypt something that was never encrypted. + var compressed = cryptoUtil.isEncryptedBuffer(stored) ? cryptoUtil.decryptBuffer(stored) : stored; + return { buffer: await gunzip(compressed), mimeType: row.mime_type, size: row.size_bytes }; +} + +// Called for a user's delete and by the expiry sweep. The object goes first; +// a row without its object is unreadable, which is worse than an orphan object +// that the next sweep would remove anyway. +async function removeObject(storageKey) { + if (!storageKey) return; + var store = objectStore(); + if (!store) return; + var { DeleteObjectCommand } = require('@aws-sdk/client-s3'); + try { + await store.client.send(new DeleteObjectCommand({ Bucket: store.Bucket, Key: storageKey })); + } catch (e) { + // An object that cannot be deleted must not block the row's removal; it + // expires with the bucket's own lifecycle rule. + console.warn('[AudioBackup] object delete failed:', e.message); + } +} + +module.exports = { + RETENTION_HOURS, + isObjectStoreConfigured, + save, + load, + removeObject, + _resetClientForTests: function() { _client = null; } +}; diff --git a/test/transcription-memory-policy.test.js b/test/transcription-memory-policy.test.js index 37c6d5e4..7a4e3eaa 100644 --- a/test/transcription-memory-policy.test.js +++ b/test/transcription-memory-policy.test.js @@ -113,3 +113,62 @@ test('recordings can be exported, and a dead recorder is reported', () => { assert.match(app, /if \(this\.notified\) return;/, 'reported once, not per chunk'); assert.match(app, /audio-recorder-failed/, 'callers can react'); }); + +test('a running recording holds the screen awake and survives a glance away', () => { + const app = read('public/js/app.js'); + // The screen sleeping suspends the recording, and the browser drops a wake + // lock whenever the page is hidden — so it has to be taken again on return, + // or one glance away ends it for the rest of the session. + assert.match(app, /navigator\.wakeLock\.request\('screen'\)/); + assert.match(app, /document\.addEventListener\('visibilitychange', function\(\) \{\s*\n\s*if \(document\.visibilityState === 'visible'\) _acquireWakeLock\(\);/); + assert.match(app, /if \(document\.visibilityState !== 'visible'\) return Promise\.resolve\(null\);/, + 'requesting while hidden would just be rejected'); + // Counted, so two recorders do not release each other's lock. + assert.match(app, /_wakeLockHolders = Math\.max\(0, _wakeLockHolders - 1\);/); + assert.match(app, /if \(_wakeLockHolders > 0 \|\| !_wakeLock\) return;/); + // Signing out must not leave the screen pinned awake. + assert.match(app, /window\.addEventListener\('account-boundary', function\(\) \{\s*\n\s*_wakeLockHolders = 0;/); + // Denied or unsupported must not stop the recording. + assert.match(app, /\.catch\(function\(\) \{ return null; \}\);/); +}); + +test('starting an already-running recorder does not throw away what it has', () => { + const app = read('public/js/app.js'); + assert.match(app, /if \(self\.mediaRecorder && self\.mediaRecorder\.state === 'recording'\) return Promise\.resolve\(\);/); + assert.match(app, /if \(self\.heldWakeLock\) \{ self\.heldWakeLock = false; releaseWakeLock\(\); \}/, 'and stopping releases the lock'); +}); + +test('a recording that ends by itself is still transcribed, and logging out stops it', () => { + const live = read('public/js/liveEncounter.js'); + // Same path as pressing Stop, so the audio is transcribed and stored rather + // than left in a tab that still claims to be recording. + assert.match(live, /document\.addEventListener\('audio-recorder-failed', function\(\) \{[\s\S]{0,160}recordBtn\.click\(\);/); + assert.match(live, /window\.addEventListener\('account-boundary', function\(\) \{[\s\S]{0,200}recorder\.stop\(\)/, + 'signing out mid-recording stops it'); +}); + +test('every recording is kept for 24 hours, not only the failures', () => { + const transcribe = read('src/routes/transcribe.js'); + const store = read('src/utils/audioBackupStore.js'); + const db = read('src/db/database.js'); + + // The audio is already on the server for transcription, so keeping it costs + // no second upload. + assert.match(transcribe, /require\('\.\.\/utils\/audioBackupStore'\)\.save\(req\.user\.id, req\.body\.module \|\| 'recording', req\.file\.buffer, mimeType\)/); + assert.match(transcribe, /console\.warn\('\[Transcribe\] backup failed \(transcription continues\)/, + 'a storage failure must not lose the transcription someone is waiting for'); + + // Object storage when configured, the encrypted database column otherwise. + assert.match(store, /if \(!env\.AUDIO_BACKUPS_S3_ENDPOINT \|\| !env\.AUDIO_BACKUPS_S3_BUCKET\) return null;/); + assert.match(store, /cryptoUtil\.encryptBuffer\(compressed\)/, 'compressed and encrypted either way'); + assert.match(store, /'recordings\/' \+ userId \+ '\/'/, 'keys are scoped to their owner'); + assert.match(store, /WHERE id = \$1 AND user_id = \$2 AND expires_at > NOW\(\)/, 'ownership and expiry are in the query'); + assert.match(store, /cryptoUtil\.isEncryptedBuffer\(stored\) \? cryptoUtil\.decryptBuffer\(stored\) : stored/, + 'rows written before encryption still read back'); + + // An expired row must take its object with it. + assert.match(db, /DELETE FROM audio_backups WHERE expires_at < NOW\(\) RETURNING storage_key/); + assert.match(db, /await store\.removeObject\(audio\.rows\[i\]\.storage_key\)/); + assert.match(db, /ALTER TABLE audio_backups ADD COLUMN IF NOT EXISTS storage_key TEXT;/, + 'existing installations get the column too'); +});