pediatric-ai-scribe-v3/public/js/voiceDictation.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

204 lines
9.1 KiB
JavaScript

var _voiceDictationInited = false;
document.addEventListener('tabChanged', function(e) {
if (e.detail.tab !== 'dictation' || _voiceDictationInited) return;
_voiceDictationInited = true;
var recordBtn = document.getElementById('dict-record-btn');
var pauseBtn = document.getElementById('dict-pause-btn');
var indicator = document.getElementById('dict-recording-indicator');
var timerEl = document.getElementById('dict-timer');
var transcript = document.getElementById('dict-transcript');
var clearBtn = document.getElementById('dict-clear');
var generateBtn = document.getElementById('dict-generate-btn');
var outputCard = document.getElementById('dict-output');
var hpiText = document.getElementById('dict-hpi-text');
var modelTag = document.getElementById('dict-model-tag');
var recorder = new AudioRecorder();
recorder._module = 'dictation';
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) {}
};
}
recordBtn.addEventListener('click', function() {
if (!isRecording) {
finalText = '';
sessionFinals = '';
recorder.start().then(function() {
isRecording = true;
isPaused = false;
recordBtn.classList.add('recording');
recordBtn.querySelector('span').textContent = 'Stop';
if (pauseBtn) pauseBtn.classList.remove('hidden');
indicator.classList.remove('hidden');
timer.start();
if (recognition) try { recognition.start(); } catch(e) {}
document.dispatchEvent(new CustomEvent('recording-started', { detail: { module: 'dict' } }));
}).catch(function() { showToast('Microphone denied', 'error'); });
} else {
isRecording = false;
isPaused = false;
var dur = timer.stop();
recordBtn.classList.remove('recording');
recordBtn.querySelector('span').textContent = 'Start Dictation';
if (pauseBtn) { pauseBtn.classList.add('hidden'); pauseBtn.innerHTML = '<i class="fas fa-pause"></i> Pause'; }
indicator.classList.add('hidden');
if (recognition) try { recognition.stop(); } catch(e) {}
document.dispatchEvent(new CustomEvent('recording-stopped', { detail: { module: 'dict' } }));
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; }
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, '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'); }
else if (data.noProvider) { if (liveText) transcript.textContent = liveText; showToast('Using live transcript', 'info'); }
else { if (liveText) transcript.textContent = liveText; showToast(data.error || 'Transcription failed — using live transcript', 'error'); }
});
}).catch(function(err) { hideBusy(); if (liveText) transcript.textContent = liveText; showToast('Transcription error: ' + (err.message || 'unknown'), 'error'); });
}
}
});
// Pause / Resume
if (pauseBtn) {
pauseBtn.addEventListener('click', function() {
if (!isRecording) return;
if (!isPaused) {
try { if (recorder.mediaRecorder && recorder.mediaRecorder.state === 'recording') recorder.mediaRecorder.pause(); } catch(e) {}
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('Paused', 'info');
} else {
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('Resumed', 'info');
}
});
}
clearBtn.addEventListener('click', function() {
transcript.textContent = ''; finalText = ''; outputCard.classList.add('hidden');
window._savedEncId_dictation = null;
var labelEl = document.getElementById('dict-label');
if (labelEl) labelEl.value = '';
var refineEl = document.getElementById('dict-refine-input');
if (refineEl) refineEl.value = '';
});
generateBtn.addEventListener('click', function() {
var text = transcript.innerText.trim();
if (!text) { showToast('No dictation', 'error'); return; }
var outputType = document.getElementById('dict-output-type').value;
var endpoint, bodyData;
showBusy('Generating...');
var memoriesPromise = (typeof getUserMemoryContext === 'function') ? getUserMemoryContext() : Promise.resolve('');
memoriesPromise.then(function(memCtx) {
if (outputType === 'soap-full' || outputType === 'soap-subjective') {
endpoint = '/api/generate-soap';
bodyData = {
transcript: text,
patientAge: document.getElementById('dict-age').value,
patientGender: document.getElementById('dict-gender').value,
type: outputType === 'soap-full' ? 'full' : 'subjective',
physicianMemories: memCtx || null,
model: getSelectedModel()
};
} else {
endpoint = '/api/generate-hpi-dictation';
bodyData = {
transcript: text,
patientAge: document.getElementById('dict-age').value,
patientGender: document.getElementById('dict-gender').value,
setting: document.getElementById('dict-setting').value,
physicianMemories: memCtx || null,
model: getSelectedModel()
};
}
return fetch(endpoint, { method: 'POST', headers: getAuthHeaders(), body: JSON.stringify(bodyData) });
})
.then(function(r) { return r.json(); })
.then(function(data) {
hideBusy();
if (data.success) {
setOutputText(hpiText, data.hpi || data.soap);
storeSourceContext('dict-hpi-text', transcript.innerText.trim());
modelTag.textContent = (data.model || '').split('/').pop();
outputCard.classList.remove('hidden');
outputCard.scrollIntoView({ behavior: 'smooth' });
showToast('Generated!', 'success');
if (typeof attachPatientEducation === 'function') attachPatientEducation('dict-hpi-text', { patientAge: document.getElementById('dict-age').value });
} else showToast(data.error || 'Failed', 'error');
})
.catch(function(err) { hideBusy(); showToast(err.message, 'error'); });
});
document.getElementById('dict-refine-btn').addEventListener('click', function() { refineDocument('dict-hpi-text', 'dict-refine-input'); });
document.getElementById('dict-shorten-btn').addEventListener('click', function() { shortenDocument('dict-hpi-text'); });
// Register load handler
if (typeof registerEncounterLoadHandler === 'function') {
registerEncounterLoadHandler('dictation', function(enc) {
if (enc.transcript) transcript.textContent = enc.transcript;
if (enc.generated_note) {
hpiText.textContent = enc.generated_note;
outputCard.classList.remove('hidden');
if (typeof attachPatientEducation === 'function') attachPatientEducation('dict-hpi-text', { patientAge: document.getElementById('dict-age').value });
}
});
}
console.log('✅ Dictation module loaded');
});