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
260 lines
12 KiB
JavaScript
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,'&').replace(/</g,'<').replace(/>/g,'>'); }
|
|
|
|
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');
|
|
});
|