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