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
259 lines
12 KiB
JavaScript
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,'&').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; }
|
|
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');
|
|
});
|