pediatric-ai-scribe-v3/public/js/voiceDictation.js
Daniel fed4bd154f
All checks were successful
Forgejo Android APK / Root app tests (push) Successful in 49s
Forgejo Android APK / Build signed APK (push) Successful in 2m8s
fix: recordings that produced nothing, and the boxes that zoomed on iOS
Measured in a real browser against the app rather than reasoned about.

The transcript boxes are contenteditable divs, and an editable div zooms on
focus exactly like an <input>. The earlier 16px sweep covered input, textarea
and select, so every workspace tab still zoomed while the calculators did not —
which is exactly what was reported. Every focusable text control in every tab
now measures 16px at phone width; the count of ones below it is zero.

Three ways a recording could end with nothing to show for it:

  - Safari supports none of the audio/webm types and throws NotSupportedError
    when handed one. Six modules built their own recorder on resume with
    "opus, else audio/webm", so resuming threw there and the recording stopped.
    There is now one codec chain in the app, and no module constructs a
    MediaRecorder of its own.

  - audio-recorder-failed is dispatched on document, and the encounter tab
    stopped its recording on any of them. The assistant's microphone failing
    ended a consultation being recorded in another tab. The recorder now
    travels with the event and the listener checks it is its own.

  - The server answers {success:true, text:''} for silence, and five modules
    assigned that straight into the transcript — emptying the box the browser
    had been filling live. It reads as a recording that vanished. Text is now
    required before overwriting, and a recording that captured nothing says so
    instead of resetting the button over an empty box.

Also: the citation counters were registered on prom-client's default registry
while the app serves its own, so they were never scraped. They read zero at
/metrics now instead of being absent, which is what the Grafana panels need.

And the reference linter passes for the first time, so scripts/e2e.sh gets past
its preflight: KaTeX is vendored (it was referenced by the assistant's LaTeX
rendering but never shipped — three 404s a page load and no math), and the
JavaScript left behind by the removed image picker, saved-chats toggle, image
gallery and visual-output panel is gone.

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

204 lines
9 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).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');
});