pediatric-ai-scribe-v3/public/js/liveEncounter.js
Daniel 523926ab17 feat: keep the screen awake while recording, and keep every recording 24h
Recording
- A screen wake lock is held for as long as a recording runs. Browsers drop
  the lock whenever the page is hidden, so it is taken again on return —
  without that, one glance away ended it for the session. The lock is
  reference counted (two recorders cannot release each other's), never
  requested while hidden (the request would just be rejected), and a denial
  or an unsupported browser leaves the recording running.
- Signing out releases it and stops the recording; nothing is sent, because
  the session that owned the audio is gone.
- start() on an already-running recorder is now a no-op instead of replacing
  the MediaRecorder and silently dropping everything captured so far.
- A recording that ends by itself — recorder error, or the microphone taken
  by another app, unplugged or revoked — takes the same path as pressing
  Stop, so it is transcribed and stored rather than left in a tab that still
  says "recording". Moving around the workspace already kept recording.

Retention
- Every recording is kept for 24 hours now, not only the ones whose
  transcription failed. /api/transcribe already has the audio, so this costs
  no second upload, and a storage failure is logged rather than thrown: it
  must never lose the transcription someone is waiting for.
- One store (src/utils/audioBackupStore.js) is shared by /api/transcribe and
  /api/audio-backups so the two cannot drift. Payload goes to object storage
  when AUDIO_BACKUPS_S3_* is set and to the encrypted Postgres column
  otherwise; metadata always stays in Postgres, so listing, ownership and
  expiry behave the same either way. Object keys are scoped by owner, and
  the expiry sweep deletes the object with the row.

Verified against the live database: round trip byte-identical, another user
reads null, 950 -> 48 bytes compressed, expired rows take their objects.

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

259 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; }
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) { transcript.textContent = data.text; showToast('Transcribed ' + dur + 's', 'success'); }
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() {
if (!isRecording) 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: try MediaRecorder.resume(), fall back to just restarting recognition
if (recorder.mediaRecorder) {
try {
if (typeof recorder.mediaRecorder.resume === 'function' && recorder.mediaRecorder.state === 'paused') {
recorder.mediaRecorder.resume();
} else if (recorder.mediaRecorder.state === 'inactive') {
// MediaRecorder was stopped (browser killed it) — restart it on the same stream
if (recorder.stream && recorder.stream.active) {
var mime = MediaRecorder.isTypeSupported('audio/webm;codecs=opus') ? 'audio/webm;codecs=opus' : 'audio/webm';
recorder.mediaRecorder = new MediaRecorder(recorder.stream, { mimeType: mime, audioBitsPerSecond: 32000 });
recorder.mediaRecorder.ondataavailable = function(e) { if (e.data.size > 0) recorder.chunks.push(e.data); };
recorder.mediaRecorder.start(1000);
}
}
} catch(e) { console.warn('[Rec] Resume error:', e.message); }
}
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');
});