pediatric-ai-scribe-v3/public/js/liveEncounter.js
Daniel b3d66caaca feat: a retried transcript goes back to the tab the audio came from
Retrying a kept recording transcribed it and put the text on the clipboard,
leaving you to find the right tab and paste. The app already had the answer: the
module was recorded with the audio. Retry now opens that tab and puts the text
in its transcript box.

Two things had to be true first.

The module was not actually being recorded. transcribeAudio never sent one, so
the server stored its default for every upload — all 28 rows in audio_backups
said "recording", and a retry had nowhere to send anything back to. Each
module's call now tags its own upload.

And the names disagreed. The recorders tagged 'encounter', 'soap', 'dictation'
while the recording-started events said 'enc', 'sick', 'dict'. One table now
holds the mapping and resolves the aliases, so the recorder that tags the
upload, the backup row that labels it and the retry that delivers it cannot
drift apart again.

Existing text is appended to, never replaced: a retry usually recovers
something on top of a live transcript, and overwriting would lose the words the
browser did hear. The box only exists once its tab's markup has been fetched, so
delivery polls briefly rather than guessing a delay, and falls back to the
clipboard if the tab never opens. An empty result says so rather than claiming
success. Backup rows now name their source and the button reads "Retry into
SOAP Note" instead of "Retry".

Verified in a browser: enc resolves to encounter, delivery switched tabs and
produced 'existing live transcript\n\nRECOVERED TEXT'.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Dv6sqaY6Vq3ChZHMem3cnU
2026-09-11 12:39:50 +02:00

260 lines
12 KiB
JavaScript

var _liveEncounterInited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'encounter' || _liveEncounterInited) return;
_liveEncounterInited = true;
var recordBtn = document.getElementById('enc-record-btn');
var pauseBtn = document.getElementById('enc-pause-btn');
var indicator = document.getElementById('enc-recording-indicator');
var timerEl = document.getElementById('enc-timer');
var transcript = document.getElementById('enc-transcript');
var clearBtn = document.getElementById('enc-clear');
var generateBtn = document.getElementById('enc-generate-btn');
var outputCard = document.getElementById('enc-output');
var hpiText = document.getElementById('enc-hpi-text');
var modelTag = document.getElementById('enc-model-tag');
var stopBtn = document.getElementById('enc-stop-btn');
var recorder = new AudioRecorder();
recorder._module = 'encounter';
var timer = createTimer(timerEl);
var isRecording = false;
var isPaused = false;
var recognition = createSpeechRecognition();
var finalText = '';
var sessionFinals = '';
function escHtml(s) { return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;'); }
if (recognition) {
recognition.onresult = function(e) {
var interim = '';
for (var i = e.resultIndex; i < e.results.length; i++) {
if (e.results[i].isFinal) {
var chunk = e.results[i][0].transcript + ' ';
var deduped = deduplicateFinal(chunk, finalText + sessionFinals);
sessionFinals += deduped;
} else {
interim = e.results[i][0].transcript;
}
}
var combined = finalText + sessionFinals;
transcript.innerHTML = escHtml(combined) + (interim ? '<span style="color:#9ca3af;">' + escHtml(interim) + '</span>' : '');
};
recognition.onend = function() {
finalText += sessionFinals;
sessionFinals = '';
if (isRecording && !isPaused) try { recognition.start(); } catch(e) {}
};
recognition.onerror = function(e) {
if (e.error === 'no-speech' || e.error === 'aborted') return;
console.warn('[SpeechRecognition] error:', e.error);
};
}
recordBtn.addEventListener('click', function() {
if (!isRecording) {
finalText = '';
sessionFinals = '';
recorder.start().then(function() {
isRecording = true;
isPaused = false;
recordBtn.classList.add('recording');
recordBtn.style.display = 'none';
if (pauseBtn) pauseBtn.classList.remove('hidden');
if (stopBtn) stopBtn.classList.remove('hidden');
indicator.classList.remove('hidden');
timer.start();
if (recognition) try { recognition.start(); } catch(e) {}
if (typeof nativeHaptic === 'function') nativeHaptic('heavy');
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(true);
if (typeof nativeStartRecordingService === 'function') nativeStartRecordingService();
showToast('Recording started', 'info');
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'enc' } }));
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
isRecording = false;
isPaused = false;
var dur = timer.stop();
recordBtn.classList.remove('recording');
recordBtn.style.display = '';
recordBtn.querySelector('span').textContent = 'Start Recording';
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; pauseBtn.classList.remove('btn-primary'); pauseBtn.classList.add('btn-ghost'); }
if (stopBtn) stopBtn.classList.add('hidden');
indicator.classList.add('hidden');
if (recognition) try { recognition.stop(); } catch(e) {}
if (typeof nativeHaptic === 'function') nativeHaptic('medium');
if (typeof nativeKeepAwake === 'function') nativeKeepAwake(false);
if (typeof nativeStopRecordingService === 'function') nativeStopRecordingService();
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'enc' } }));
// If no server transcription API, use live transcript directly (no upload)
var liveText = (finalText + sessionFinals).trim();
if (window._transcribeAvailable === false) {
recorder.stop().then(function() {});
if (liveText) transcript.textContent = liveText;
showToast('Using browser speech recognition (no transcription API configured)', 'info');
} else {
showBusy('Transcribing...');
recorder.stop().then(function(blob) {
if (!blob || blob.size === 0) {
hideBusy();
if (liveText) { transcript.textContent = liveText; return; }
// Nothing was captured and the browser heard nothing either. Saying
// so is the whole point: this used to reset the button and leave an
// empty box, which reads as "it transcribed silence".
showToast('No audio was captured — check the microphone and try again', 'error');
return;
}
if (blob.size > 24 * 1024 * 1024) {
hideBusy();
transcript.textContent = liveText;
showToast('Recording too large for AI transcription — using live transcript', 'info');
return;
}
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.
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'); }
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Failed', 'error'); }
});
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast(err.message, 'error'); });
}
}
});
// 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(e) {
if (!isRecording) return;
// Only this tab's recorder. The event is on document, so the assistant's
// microphone failing must not end the consultation being recorded here.
if (e && e.detail && e.detail.recorder && e.detail.recorder !== recorder) 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() {
if (!isRecording) return;
// Temporarily show recordBtn so .click() triggers the stop flow
recordBtn.style.display = '';
recordBtn.click();
});
}
// Pause / Resume
if (pauseBtn) {
pauseBtn.addEventListener('click', function() {
if (!isRecording) return;
if (!isPaused) {
// Pause: try MediaRecorder.pause(), fall back to stop+accumulate
if (recorder.mediaRecorder) {
try {
if (typeof recorder.mediaRecorder.pause === 'function' && recorder.mediaRecorder.state === 'recording') {
recorder.mediaRecorder.pause();
}
} catch(e) { console.warn('[Rec] Pause not supported:', e.message); }
}
timer.stop();
isPaused = true;
pauseBtn.innerHTML = '<i class="fas fa-play"></i> Resume';
pauseBtn.classList.add('btn-primary');
pauseBtn.classList.remove('btn-ghost');
if (recognition) try { recognition.stop(); } catch(e) {}
showToast('Recording paused', 'info');
} else {
// Resume: the recorder resumes, or is rebuilt on the same stream if the
// browser stopped it outright. Recognition restarts either way.
recorder.resumeCapture();
timer.resume();
isPaused = false;
pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause';
pauseBtn.classList.remove('btn-primary');
pauseBtn.classList.add('btn-ghost');
if (recognition) try { recognition.start(); } catch(e) {}
showToast('Recording resumed', 'info');
}
});
}
clearBtn.addEventListener('click', function() {
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
window._savedEncId_encounter = null;
var labelEl = document.getElementById('enc-label');
if (labelEl) labelEl.value = '';
var refineEl = document.getElementById('enc-refine-input');
if (refineEl) refineEl.value = '';
});
generateBtn.addEventListener('click', function() {
var text = transcript.innerText.trim();
if (!text) { showToast('No transcript', 'error'); return; }
showBusy('Generating HPI...');
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
return fetch('/api/generate-hpi-encounter', {
method: 'POST',
headers: getAuthHeaders(),
body: JSON.stringify({
transcript: text,
patientAge: document.getElementById('enc-age').value,
patientGender: document.getElementById('enc-gender').value,
setting: document.getElementById('enc-setting').value,
physicianMemories: memCtx || null,
model: getSelectedModel()
})
});
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) {
setOutputText(hpiText, data.hpi);
storeSourceContext('enc-hpi-text', transcript.innerText.trim());
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('HPI generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('enc-hpi-text', { patientAge: document.getElementById('enc-age').value });
if (typeof suggestBillingCodes === 'function') suggestBillingCodes('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value, document.getElementById('enc-setting').value);
if (typeof suggestDontMiss === 'function') suggestDontMiss('enc-hpi-text', data.hpi, 'hpi', document.getElementById('enc-age').value);
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
});
// Refine & Shorten
document.getElementById('enc-refine-btn').addEventListener('click', function() { refineDocument('enc-hpi-text', 'enc-refine-input'); });
document.getElementById('enc-shorten-btn').addEventListener('click', function() { shortenDocument('enc-hpi-text'); });
// Register load handler for resuming saved encounters
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('encounter', function(enc) {
if (enc.transcript) transcript.textContent = enc.transcript;
if (enc.generated_note) {
hpiText.textContent = enc.generated_note;
outputCard.classList.remove('hidden');
}
try { var pd = JSON.parse(enc.partial_data || '{}'); if (pd.age) document.getElementById('enc-age').value = pd.age; if (pd.gender) document.getElementById('enc-gender').value = pd.gender; } catch(e) {}
});
}
console.log('✅ Encounter module loaded');
});