diff --git a/public/index.html b/public/index.html
index a1493cac..85eedc46 100644
--- a/public/index.html
+++ b/public/index.html
@@ -469,6 +469,7 @@
integrity="sha384-JUh163oCRItcbPme8pYnROHQMC6fNKTBWtRG3I3I0erJkzNgL7uxKlNwcrcFKeqF"
crossorigin="anonymous" referrerpolicy="no-referrer" defer>
+
diff --git a/public/js/app.js b/public/js/app.js
index 07666966..6c6d6cbb 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -1222,11 +1222,14 @@ function checkTranscribeStatus() {
.catch(function() { window._transcribeAvailable = false; });
}
-function transcribeAudio(blob) {
- return _serverTranscribe(blob);
+// module says which tab this audio came from. The server stores it with the
+// 24h backup, which is what lets a later retry put the transcript back where it
+// belongs instead of only on the clipboard.
+function transcribeAudio(blob, module) {
+ return _serverTranscribe(blob, module);
}
-function _serverTranscribe(blob) {
+function _serverTranscribe(blob, module) {
// If no server transcription is configured, skip upload entirely
if (window._transcribeAvailable === false) {
return Promise.resolve({ success: false, noProvider: true, error: 'No transcription API configured — using live transcript' });
@@ -1235,6 +1238,8 @@ function _serverTranscribe(blob) {
var startTime = Date.now();
var formData = new FormData();
formData.append('audio', blob, 'audio.webm');
+ var moduleName = window.RecordingModules ? window.RecordingModules.normalize(module) : module;
+ if (moduleName) formData.append('module', moduleName);
var headers = getAuthHeaders();
delete headers['Content-Type']; // FormData supplies its own boundary.
return fetch('/api/transcribe', {
diff --git a/public/js/audioBackup.js b/public/js/audioBackup.js
index ede140d4..089d0352 100644
--- a/public/js/audioBackup.js
+++ b/public/js/audioBackup.js
@@ -245,7 +245,73 @@ function guardTransaction(tx, owner) {
});
};
- window.retryAudioBackup = function(id) {
+
+ // Put a retried transcript back where the audio came from.
+ //
+ // The module travelled with the recording, so the app already knows which tab
+ // and which box. Opening that tab and inserting the text is what someone was
+ // going to do by hand with the clipboard copy; doing it for them is the whole
+ // point of having stored the module.
+ //
+ // Existing text is never destroyed — a retry is usually recovering something
+ // ON TOP of a live transcript, and silently replacing it would lose the words
+ // the browser did hear. The new text is appended and revealed.
+ function deliverTranscript(module, text) {
+ var entry = window.RecordingModules && window.RecordingModules.lookup(module);
+ if (!entry || !text) return false;
+ if (typeof window.activateTab !== 'function') return false;
+ if (!window.activateTab(entry.tab)) return false;
+
+ // The tab's markup is fetched on first activation, so the box may not exist
+ // for a moment. Poll briefly rather than guessing a delay.
+ var attempts = 0;
+ (function place() {
+ var box = document.getElementById(entry.target);
+ if (!box) {
+ if (attempts++ > 40) {
+ copyToClipboard(text);
+ showToast('Transcript copied — could not open ' + entry.label, 'info');
+ return;
+ }
+ return setTimeout(place, 50);
+ }
+ var existing = (box.textContent || '').trim();
+ box.textContent = existing ? existing + '\n\n' + text : text;
+ box.scrollIntoView({ block: 'center' });
+ try { box.focus(); } catch (e) {}
+ showToast('Transcript added to ' + entry.label, 'success');
+ }());
+ return true;
+ }
+
+ function copyToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ navigator.clipboard.writeText(text);
+ return true;
+ }
+ return false;
+ }
+
+ // One place decides what happens with a retried transcript, so the server and
+ // local paths cannot drift apart.
+ function handleRetryResult(data, module) {
+ if (!data || !data.success) {
+ showToast('Retry failed: ' + ((data && data.error) || 'unknown'), 'error');
+ return data;
+ }
+ if (!data.text) {
+ showToast('That recording still transcribes to nothing', 'error');
+ return data;
+ }
+ if (deliverTranscript(module, data.text)) return data;
+ // Unknown module, or no tab to open: the clipboard is still better than
+ // nothing, and says so rather than claiming success.
+ if (copyToClipboard(data.text)) showToast('Transcript copied to clipboard', 'info');
+ else showToast('Transcribed, but there was nowhere to put it', 'info');
+ return data;
+ }
+
+ window.retryAudioBackup = function(id, module) {
var owner = boundary.capture();
if (!owner) return Promise.reject(boundary.error());
if (typeof id === 'string' && id.startsWith('server_')) {
@@ -258,19 +324,10 @@ function guardTransaction(tx, owner) {
.then(function(blob) {
if (!boundary.valid(owner)) throw boundary.error();
window._lastAudioBackupId = id;
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, module).then(function(data) {
if (!boundary.valid(owner)) throw boundary.error();
hideLoading();
- if (data.success) {
- showToast('Backup transcribed!', 'success');
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(data.text);
- showToast('Transcript copied to clipboard', 'info');
- }
- } else {
- showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
- }
- return data;
+ return handleRetryResult(data, module);
});
})
.catch(function(err) { hideLoading(); showToast('Retry failed: ' + err.message, 'error'); });
@@ -292,19 +349,10 @@ function guardTransaction(tx, owner) {
if (!boundary.valid(owner)) throw boundary.error();
showLoading('Re-transcribing audio backup...');
window._lastAudioBackupId = id;
- return transcribeAudio(record.blob).then(function(data) {
+ return transcribeAudio(record.blob, module || record.module).then(function(data) {
if (!boundary.valid(owner)) throw boundary.error();
hideLoading();
- if (data.success) {
- showToast('Backup transcribed!', 'success');
- if (navigator.clipboard && navigator.clipboard.writeText) {
- navigator.clipboard.writeText(data.text);
- showToast('Transcript copied to clipboard', 'info');
- }
- } else {
- showToast('Retry failed: ' + (data.error || 'unknown'), 'error');
- }
- return data;
+ return handleRetryResult(data, module || record.module);
});
});
};
@@ -360,7 +408,8 @@ function guardTransaction(tx, owner) {
title.style.display = 'flex';
title.style.alignItems = 'center';
title.style.gap = '6px';
- title.appendChild(document.createTextNode((b.module || '') + ' recording '));
+ title.appendChild(document.createTextNode(
+ (window.RecordingModules ? window.RecordingModules.label(b.module) : (b.module || 'Recording')) + ' '));
var sourceTag = document.createElement('span');
sourceTag.style.fontSize = '10px';
@@ -382,10 +431,15 @@ function guardTransaction(tx, owner) {
var retryBtn = document.createElement('button');
retryBtn.className = 'btn-sm btn-primary audio-backup-retry';
retryBtn.dataset.id = b.id;
+ retryBtn.dataset.module = b.module || '';
var retryIcon = document.createElement('i');
retryIcon.className = 'fas fa-rotate-right';
retryBtn.appendChild(retryIcon);
- retryBtn.appendChild(document.createTextNode(' Retry'));
+ var retryTarget = window.RecordingModules && window.RecordingModules.lookup(b.module);
+ retryBtn.appendChild(document.createTextNode(retryTarget ? ' Retry into ' + retryTarget.label : ' Retry'));
+ retryBtn.title = retryTarget
+ ? 'Transcribe again and add the text to ' + retryTarget.label
+ : 'Transcribe again and copy the text to the clipboard';
var downloadBtn = document.createElement('button');
downloadBtn.className = 'btn-sm btn-ghost audio-backup-download';
@@ -411,7 +465,7 @@ function guardTransaction(tx, owner) {
container.appendChild(row);
});
container.querySelectorAll('.audio-backup-retry').forEach(function(btn) {
- btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id); });
+ btn.addEventListener('click', function() { retryAudioBackup(btn.dataset.id, btn.dataset.module); });
});
container.querySelectorAll('.audio-backup-download').forEach(function(btn) {
btn.addEventListener('click', function() { downloadAudioBackup(btn.dataset.id, btn.dataset.stamp); });
diff --git a/public/js/ed-encounters.js b/public/js/ed-encounters.js
index 5e1d5419..320feb17 100644
--- a/public/js/ed-encounters.js
+++ b/public/js/ed-encounters.js
@@ -316,7 +316,7 @@
persistLocal();
return;
}
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, 'ed').then(function(data) {
hideBusy();
if (data && data.success && data.text) {
if (transcriptEl) transcriptEl.textContent = data.text;
diff --git a/public/js/liveEncounter.js b/public/js/liveEncounter.js
index b9934fef..acb8aac6 100644
--- a/public/js/liveEncounter.js
+++ b/public/js/liveEncounter.js
@@ -111,7 +111,7 @@ var _liveEncounterInited = false;
showToast('Recording too large for AI transcription — using live transcript', 'info');
return;
}
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, 'encounter').then(function(data) {
hideBusy();
// A successful call that returns no words must not wipe what the
// browser already heard — that looked exactly like a lost recording.
diff --git a/public/js/recordingModules.js b/public/js/recordingModules.js
new file mode 100644
index 00000000..9afe9c0a
--- /dev/null
+++ b/public/js/recordingModules.js
@@ -0,0 +1,58 @@
+// ============================================================
+// RECORDING MODULES
+// Where a recording came from, and where its transcript belongs.
+//
+// Every recording is kept for 24 hours, so a transcription that fails or comes
+// back empty can be retried later. Retrying used to hand you the text on the
+// clipboard and leave you to find the right tab and paste it — which is work
+// the app already has the answer to: the module was recorded with the audio.
+//
+// One table, because three things have to agree about a module: the recorder
+// that tags the upload, the backup list that labels it, and the retry that
+// delivers the transcript. They disagreed before — recorders tagged
+// 'encounter' while the recording-started events said 'enc' — so this also
+// normalises the aliases rather than leaving each caller to guess.
+// ============================================================
+
+(function () {
+ var MODULES = {
+ encounter: { tab: 'encounter', target: 'enc-transcript', label: 'Encounter HPI' },
+ dictation: { tab: 'dictation', target: 'dict-transcript', label: 'Dictation HPI' },
+ soap: { tab: 'soap', target: 'soap-transcript', label: 'SOAP Note' },
+ sick: { tab: 'sickvisit', target: 'sick-transcript', label: 'Sick Visit' },
+ wellvisit: { tab: 'wellvisit', target: 'wv-transcript', label: 'Well Visit' },
+ ed: { tab: 'ed', target: 'ed-transcript', label: 'ED Encounter' }
+ };
+
+ // The short names the recording-started/stopped events have always used, and
+ // the plain ones a person might see in a backup row.
+ var ALIASES = {
+ enc: 'encounter', dict: 'dictation', sickvisit: 'sick', 'sick-visit': 'sick',
+ wv: 'wellvisit', 'well-visit': 'wellvisit', 'ed-encounter': 'ed'
+ };
+
+ function normalize(module) {
+ var name = String(module == null ? '' : module).trim().toLowerCase();
+ return ALIASES[name] || name;
+ }
+
+ function lookup(module) {
+ return MODULES[normalize(module)] || null;
+ }
+
+ // A label for a backup row. Unknown modules keep their raw name rather than
+ // being hidden, because an unrecognised one is worth seeing.
+ function label(module) {
+ var entry = lookup(module);
+ if (entry) return entry.label;
+ var name = normalize(module);
+ return name && name !== 'recording' ? name : 'Recording';
+ }
+
+ window.RecordingModules = {
+ normalize: normalize,
+ lookup: lookup,
+ label: label,
+ known: function () { return Object.keys(MODULES); }
+ };
+}());
diff --git a/public/js/shadess.js b/public/js/shadess.js
index 728cf48a..3d328f3f 100644
--- a/public/js/shadess.js
+++ b/public/js/shadess.js
@@ -748,7 +748,7 @@ function setupShadessModule() {
showBusy('Transcribing...');
_wvRecorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideBusy(); if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); return; }
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, 'wellvisit').then(function(data) {
hideBusy();
if (data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; _wvTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else if (data.success) { if (_wvTranscript.trim() && transcriptEl) transcriptEl.textContent = _wvTranscript.trim(); showToast('No speech detected in the recording', 'error'); }
diff --git a/public/js/sickVisit.js b/public/js/sickVisit.js
index 3678b819..df261b0a 100644
--- a/public/js/sickVisit.js
+++ b/public/js/sickVisit.js
@@ -177,7 +177,7 @@
showBusy('Transcribing...');
_sickRecorder.stop().then(function(blob) {
if (!blob || blob.size === 0) { hideBusy(); if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); return; }
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, 'sick').then(function(data) {
hideBusy();
if (data.success && data.text) { if (transcriptEl) transcriptEl.textContent = data.text; _sickTranscript = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else if (data.success) { if (_sickTranscript.trim() && transcriptEl) transcriptEl.textContent = _sickTranscript.trim(); showToast('No speech detected in the recording', 'error'); }
diff --git a/public/js/soap.js b/public/js/soap.js
index f78001e3..85958562 100644
--- a/public/js/soap.js
+++ b/public/js/soap.js
@@ -97,7 +97,7 @@
showToast('Recording too large for AI transcription — using live transcript', 'info');
return;
}
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, 'soap').then(function(data) {
hideBusy();
if (data.success && data.text) transcript.textContent = data.text;
else if (data.success) { if (liveText) transcript.textContent = liveText; showToast('No speech detected in the recording', 'error'); }
diff --git a/public/js/voiceDictation.js b/public/js/voiceDictation.js
index b833bbd9..c16753fe 100644
--- a/public/js/voiceDictation.js
+++ b/public/js/voiceDictation.js
@@ -87,7 +87,7 @@ var _voiceDictationInited = false;
showToast('Recording too large for AI transcription — using live transcript', 'info');
return;
}
- return transcribeAudio(blob).then(function(data) {
+ return transcribeAudio(blob, 'dictation').then(function(data) {
hideBusy();
if (data.success && data.text) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
else if (data.success) { if (liveText) transcript.textContent = liveText; showToast('No speech detected in the recording', 'error'); }
diff --git a/test/transcription-memory-policy.test.js b/test/transcription-memory-policy.test.js
index ebb12e44..ae5458e2 100644
--- a/test/transcription-memory-policy.test.js
+++ b/test/transcription-memory-policy.test.js
@@ -254,3 +254,54 @@ test('a transcription that finds no words leaves the live transcript alone', ()
assert.match(read('public/js/liveEncounter.js'),
/showToast\('No audio was captured — check the microphone and try again', 'error'\);/);
});
+
+test('a retried transcript goes back to the tab the audio came from', () => {
+ // The module travels with the recording, so the app already knows which tab
+ // and which box. Handing someone the clipboard and leaving them to find the
+ // right tab was work the app could do itself.
+ const modules = read('public/js/recordingModules.js');
+ for (const [name, tab, target] of [
+ ['encounter', 'encounter', 'enc-transcript'],
+ ['dictation', 'dictation', 'dict-transcript'],
+ ['soap', 'soap', 'soap-transcript'],
+ ['sick', 'sickvisit', 'sick-transcript'],
+ ['wellvisit', 'wellvisit', 'wv-transcript'],
+ ['ed', 'ed', 'ed-transcript'],
+ ]) {
+ const row = new RegExp(name + ":\\s*\\{\\s*tab:\\s*'" + tab + "',\\s*target:\\s*'" + target + "'");
+ assert.match(modules, row, name + ' maps to its own tab and box');
+ }
+ // The recorders and the recording-started events used different names for the
+ // same module — 'encounter' against 'enc' — so the aliases are resolved here
+ // rather than left for each caller to guess.
+ assert.match(modules, /enc: 'encounter', dict: 'dictation'/);
+
+ const backup = read('public/js/audioBackup.js');
+ assert.match(backup, /function deliverTranscript\(module, text\)/);
+ assert.match(backup, /if \(!window\.activateTab\(entry\.tab\)\) return false;/, 'it opens that tab');
+ // A retry usually recovers text ON TOP of a live transcript, so replacing the
+ // box would lose the words the browser did hear.
+ assert.match(backup, /box\.textContent = existing \? existing \+ '\\n\\n' \+ text : text;/,
+ 'existing text is appended to, never replaced');
+ // The box only exists once the tab's markup has been fetched.
+ assert.match(backup, /if \(attempts\+\+ > 40\)/, 'it waits for the component rather than guessing a delay');
+ assert.match(backup, /showToast\('Transcript copied — could not open '/, 'and falls back to the clipboard');
+ assert.match(backup, /if \(!data\.text\) \{[\s\S]{0,120}still transcribes to nothing/,
+ 'an empty result says so instead of claiming success');
+});
+
+test('every recording is uploaded with the module it came from', () => {
+ // Without this the server stored "recording" for all of them — 28 rows of it —
+ // and a retry had nowhere to send the text back to.
+ const app = read('public/js/app.js');
+ assert.match(app, /function transcribeAudio\(blob, module\)/);
+ assert.match(app, /if \(moduleName\) formData\.append\('module', moduleName\);/);
+
+ for (const [file, module] of [
+ ['liveEncounter', 'encounter'], ['soap', 'soap'], ['voiceDictation', 'dictation'],
+ ['sickVisit', 'sick'], ['ed-encounters', 'ed'], ['shadess', 'wellvisit'],
+ ]) {
+ assert.match(read('public/js/' + file + '.js'), new RegExp("transcribeAudio\\(blob, '" + module + "'\\)"),
+ file + ' tags its uploads');
+ }
+});