feat: keep the screen awake while recording, and keep every recording 24h

Recording
- A screen wake lock is held for as long as a recording runs. Browsers drop
  the lock whenever the page is hidden, so it is taken again on return —
  without that, one glance away ended it for the session. The lock is
  reference counted (two recorders cannot release each other's), never
  requested while hidden (the request would just be rejected), and a denial
  or an unsupported browser leaves the recording running.
- Signing out releases it and stops the recording; nothing is sent, because
  the session that owned the audio is gone.
- start() on an already-running recorder is now a no-op instead of replacing
  the MediaRecorder and silently dropping everything captured so far.
- A recording that ends by itself — recorder error, or the microphone taken
  by another app, unplugged or revoked — takes the same path as pressing
  Stop, so it is transcribed and stored rather than left in a tab that still
  says "recording". Moving around the workspace already kept recording.

Retention
- Every recording is kept for 24 hours now, not only the ones whose
  transcription failed. /api/transcribe already has the audio, so this costs
  no second upload, and a storage failure is logged rather than thrown: it
  must never lose the transcription someone is waiting for.
- One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and
  /api/audio-backups so the two cannot drift. Payload goes to object storage
  when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column
  otherwise; metadata always stays in Postgres, so listing, ownership and
  expiry behave the same either way. Object keys are scoped by owner, and
  the expiry sweep deletes the object with the row.

Verified against the live database: round trip byte-identical, another user
reads null, 950 -> 48 bytes compressed, expired rows take their objects.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
This commit is contained in:
Daniel 2026-09-10 16:42:31 +02:00
parent dca1ebb6fe
commit 523926ab17
7 changed files with 309 additions and 38 deletions

View file

@ -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() {

View file

@ -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() {

View file

@ -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'");

View file

@ -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'); }
});

View file

@ -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

View file

@ -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; }
};

View file

@ -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');
});