From 48a3b06ebb101b5d9b5c9bb8d1b040dc224cdcd2 Mon Sep 17 00:00:00 2001 From: Daniel Date: Thu, 10 Sep 2026 15:58:58 +0200 Subject: [PATCH] feat: export a recording; report a recorder that has silently died MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU --- public/js/app.js | 31 +++++++++++++ public/js/audioBackup.js | 56 ++++++++++++++++++++++++ test/transcription-memory-policy.test.js | 20 +++++++++ 3 files changed, 107 insertions(+) diff --git a/public/js/app.js b/public/js/app.js index 7fdfc8f0..cb8741f8 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -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) { diff --git a/public/js/audioBackup.js b/public/js/audioBackup.js index 7d64d6fc..ede140d4 100644 --- a/public/js/audioBackup.js +++ b/public/js/audioBackup.js @@ -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() { diff --git a/test/transcription-memory-policy.test.js b/test/transcription-memory-policy.test.js index 80953db6..37c6d5e4 100644 --- a/test/transcription-memory-policy.test.js +++ b/test/transcription-memory-policy.test.js @@ -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'); +});