feat: export a recording; report a recorder that has silently died

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
This commit is contained in:
Daniel 2026-09-10 15:58:58 +02:00
parent f0f48a3578
commit 48a3b06ebb
3 changed files with 107 additions and 0 deletions

View file

@ -991,9 +991,40 @@ AudioRecorder.prototype.start = function() {
if (mime) options.mimeType = mime;
self.mediaRecorder = new MediaRecorder(stream, options);
self.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) self.chunks.push(e.data); };
// A recording can stop being a recording without anyone noticing: the
// recorder can error, and the microphone can be taken away entirely (another
// app claims it, a headset is unplugged, permission is revoked). Neither was
// reported, so the tab kept showing "recording" while capturing nothing.
// Whatever was captured up to that point is kept — the chunks are already in
// self.chunks — so stop() still returns the audio so far.
self.failure = null;
self.mediaRecorder.onerror = function(event) {
self.failure = (event && event.error && event.error.message) || 'The recorder stopped unexpectedly';
self.notifyFailure();
};
stream.getAudioTracks().forEach(function(track) {
track.addEventListener('ended', function() {
self.failure = 'The microphone became unavailable';
self.notifyFailure();
});
});
self.mediaRecorder.start(1000);
});
};
// Announced once per recorder, so a caller that is listening can stop cleanly
// and the person is told rather than left recording silence.
AudioRecorder.prototype.notifyFailure = function() {
if (this.notified) return;
this.notified = true;
var message = this.failure || 'Recording stopped unexpectedly';
try {
if (typeof showToast === 'function') showToast(message + '. Stop and check your microphone.', 'error');
} catch (e) {}
try {
document.dispatchEvent(new CustomEvent('audio-recorder-failed', { detail: { message: message } }));
} catch (e) {}
};
AudioRecorder.prototype.stop = function() {
var self = this;
return new Promise(function(resolve) {

View file

@ -202,6 +202,49 @@ function guardTransaction(tx, owner) {
}
// 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());
@ -344,6 +387,15 @@ function guardTransaction(tx, owner) {
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;
@ -354,12 +406,16 @@ function guardTransaction(tx, owner) {
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() {

View file

@ -93,3 +93,23 @@ test('the STT picker offers what the gateway has, not a hardcoded list', () => {
'the built-in list survives only as a fallback');
assert.match(prefs, /model === adminSttModel \? ' \(default\)' : ''/, 'the admin default is marked');
});
// A recording is significant clinical material: it must be possible to take a
// copy out of the app, and a recording that has silently stopped must say so.
test('recordings can be exported, and a dead recorder is reported', () => {
const backup = read('public/js/audioBackup.js');
assert.match(backup, /window\.downloadAudioBackup = function\(id, stamp\)/);
assert.match(backup, /'\/api\/audio-backups\/' \+ id\.replace\('server_', ''\) \+ '\/audio'/, 'server-side copies');
assert.match(backup, /objectStore\(STORE_NAME\)\.get\(localId\)/, 'and local ones');
// A local record belongs to one account; another must not be able to pull it.
assert.match(backup, /!boundary\.valid\(owner\) \|\| record\.owner !== owner/);
assert.match(backup, /audio-backup-download/, 'the list offers it');
assert.match(backup, /indexOf\('mp4'\) !== -1 \? 'm4a'/, 'the extension matches what was recorded');
const app = read('public/js/app.js');
assert.match(app, /self\.mediaRecorder\.onerror = function\(event\)/);
assert.match(app, /track\.addEventListener\('ended'/, 'the microphone being taken away is a failure too');
assert.match(app, /AudioRecorder\.prototype\.notifyFailure/);
assert.match(app, /if \(this\.notified\) return;/, 'reported once, not per chunk');
assert.match(app, /audio-recorder-failed/, 'callers can react');
});