Add Web Speech Recognition option for real-time streaming
Provides two transcription options: 1. Browser Whisper (Offline, Batch) - RECOMMENDED - 100% offline, zero network calls - HIPAA-compliant, audio never leaves device - Highest accuracy (Whisper) - Processes after recording (batch mode) - Models self-hosted, bundled in v2 2. Web Speech API (Real-time, Streaming) - EXPERIMENTAL - Real-time transcription (see words as you speak) - Uses browser's built-in speech recognition - ⚠️ Sends audio to cloud (Chrome/Edge → Google) - ⚠️ NOT HIPAA-compliant - Requires user consent with clear warnings Features: - Settings UI for both options - Clear privacy warnings for Web Speech - Mutual exclusion (only one active at a time) - Browser detection shows which provider is used - Confirmation dialog before enabling Web Speech Use Cases: - Clinical/HIPAA: Use Browser Whisper only - Personal/Non-clinical: Can use Web Speech for real-time feedback - Maximum privacy: Browser Whisper (offline) - Maximum speed: Web Speech (if privacy not required) Implementation: - speechRecognition.js: Web Speech API wrapper - transcriptionSettings.js: Settings UI handler - Privacy info displayed per browser User can choose based on their privacy vs. speed preference.
This commit is contained in:
parent
a7dd08c9d1
commit
196f4432f0
4 changed files with 352 additions and 0 deletions
|
|
@ -61,6 +61,30 @@
|
|||
<p style="font-size:11px;color:var(--orange);margin:4px 0 0;display:none;" id="browser-whisper-csp-warning"><i class="fas fa-exclamation-triangle"></i> <strong>Network/Firewall Issue:</strong> If model download fails, check that <code>cdn.jsdelivr.net</code> and <code>huggingface.co</code> are not blocked. Server transcription will be used as fallback.</p>
|
||||
</div>
|
||||
|
||||
<!-- Web Speech Recognition (Real-time Streaming) -->
|
||||
<div class="settings-section card" id="web-speech-section" style="border-left:3px solid var(--orange);">
|
||||
<h3><i class="fas fa-wave-square"></i> Real-Time Streaming Transcription</h3>
|
||||
<div style="background:var(--orange-light);padding:12px;border-radius:6px;margin-bottom:12px;">
|
||||
<p style="font-size:13px;color:var(--orange-dark);margin:0;"><i class="fas fa-exclamation-triangle"></i> <strong>Privacy Warning:</strong> Uses your browser's built-in speech recognition, which <strong>may send audio to cloud servers</strong> (Chrome/Edge send to Google). Only enable if you accept this trade-off for real-time transcription.</p>
|
||||
</div>
|
||||
<p style="font-size:13px;color:var(--g600);">See words appear as you speak (streaming). Overrides browser and server transcription when enabled. <strong>Not HIPAA-compliant</strong> in most browsers.</p>
|
||||
|
||||
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;margin-bottom:12px;">
|
||||
<label style="font-size:13px;font-weight:600;">Enable real-time streaming:</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer;">
|
||||
<input type="checkbox" id="web-speech-enabled" style="accent-color:var(--orange);width:16px;height:16px;">
|
||||
<span style="font-size:13px;" id="web-speech-status">Off</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div id="web-speech-privacy-info" style="font-size:12px;color:var(--g500);padding:10px;background:var(--g50);border-radius:6px;margin-top:8px;">
|
||||
<p style="margin:0 0 4px;font-weight:600;">Current Browser:</p>
|
||||
<p style="margin:0;" id="web-speech-browser-info">Detecting...</p>
|
||||
</div>
|
||||
|
||||
<p style="font-size:11px;color:var(--g400);margin:8px 0 0;"><i class="fas fa-info-circle"></i> <strong>Trade-off:</strong> Immediate transcription vs. privacy. For maximum privacy, use Browser Whisper (offline batch mode) or Server transcription with HIPAA-eligible provider.</p>
|
||||
</div>
|
||||
|
||||
<!-- 2FA -->
|
||||
<div class="settings-section card">
|
||||
<h3><i class="fas fa-shield-halved"></i> Two-Factor Authentication</h3>
|
||||
|
|
|
|||
|
|
@ -303,6 +303,8 @@
|
|||
<script defer src="/js/audioBackup.js"></script>
|
||||
<script defer src="/js/correctionTracker.js"></script>
|
||||
<script defer src="/js/browserWhisper.js"></script>
|
||||
<script defer src="/js/speechRecognition.js"></script>
|
||||
<script defer src="/js/transcriptionSettings.js"></script>
|
||||
<script defer src="/js/voicePreferences.js"></script>
|
||||
<script defer src="/js/app.js"></script>
|
||||
<script defer src="/js/auth.js"></script>
|
||||
|
|
|
|||
160
public/js/speechRecognition.js
Normal file
160
public/js/speechRecognition.js
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// ============================================================
|
||||
// WEB SPEECH RECOGNITION — Real-time streaming transcription
|
||||
// ⚠️ PRIVACY WARNING: Uses browser's built-in ASR which may
|
||||
// send audio to cloud servers (Chrome/Edge → Google servers)
|
||||
// Only use if you accept this trade-off for real-time streaming
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
|
||||
var STORAGE_ENABLED = 'ped_web_speech_enabled';
|
||||
|
||||
var SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
var _recognition = null;
|
||||
var _transcript = '';
|
||||
var _isListening = false;
|
||||
var _onPartialCallback = null;
|
||||
var _onFinalCallback = null;
|
||||
|
||||
window.WebSpeechRecognition = {
|
||||
|
||||
isSupported: function() {
|
||||
return typeof SpeechRecognition !== 'undefined';
|
||||
},
|
||||
|
||||
isEnabled: function() {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_ENABLED) === '1';
|
||||
} catch(e) {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
|
||||
setEnabled: function(val) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_ENABLED, val ? '1' : '0');
|
||||
} catch(e) {}
|
||||
},
|
||||
|
||||
startListening: function(options) {
|
||||
if (!this.isSupported()) {
|
||||
return Promise.reject(new Error('Web Speech API not supported'));
|
||||
}
|
||||
|
||||
if (!this.isEnabled()) {
|
||||
return Promise.reject(new Error('Web Speech Recognition not enabled'));
|
||||
}
|
||||
|
||||
_transcript = '';
|
||||
_isListening = true;
|
||||
|
||||
var opts = options || {};
|
||||
_onPartialCallback = opts.onPartial || function() {};
|
||||
_onFinalCallback = opts.onFinal || function() {};
|
||||
|
||||
_recognition = new SpeechRecognition();
|
||||
_recognition.continuous = true; // Keep listening
|
||||
_recognition.interimResults = true; // Show partial results
|
||||
_recognition.lang = opts.language || 'en-US';
|
||||
_recognition.maxAlternatives = 1;
|
||||
|
||||
return new Promise(function(resolve, reject) {
|
||||
_recognition.onstart = function() {
|
||||
console.log('[WebSpeech] Started listening');
|
||||
};
|
||||
|
||||
_recognition.onresult = function(event) {
|
||||
var interim = '';
|
||||
var final = '';
|
||||
|
||||
for (var i = event.resultIndex; i < event.results.length; i++) {
|
||||
var transcript = event.results[i][0].transcript;
|
||||
|
||||
if (event.results[i].isFinal) {
|
||||
final += transcript + ' ';
|
||||
_transcript += transcript + ' ';
|
||||
} else {
|
||||
interim += transcript;
|
||||
}
|
||||
}
|
||||
|
||||
// Callback with partial results (shown in real-time)
|
||||
if (interim && _onPartialCallback) {
|
||||
_onPartialCallback(interim);
|
||||
}
|
||||
|
||||
// Callback with final results (confirmed words)
|
||||
if (final && _onFinalCallback) {
|
||||
_onFinalCallback(final.trim());
|
||||
}
|
||||
};
|
||||
|
||||
_recognition.onerror = function(event) {
|
||||
console.error('[WebSpeech] Error:', event.error);
|
||||
_isListening = false;
|
||||
|
||||
if (event.error === 'no-speech') {
|
||||
reject(new Error('No speech detected'));
|
||||
} else if (event.error === 'not-allowed') {
|
||||
reject(new Error('Microphone permission denied'));
|
||||
} else {
|
||||
reject(new Error('Speech recognition error: ' + event.error));
|
||||
}
|
||||
};
|
||||
|
||||
_recognition.onend = function() {
|
||||
_isListening = false;
|
||||
console.log('[WebSpeech] Stopped listening');
|
||||
resolve(_transcript.trim());
|
||||
};
|
||||
|
||||
try {
|
||||
_recognition.start();
|
||||
} catch (e) {
|
||||
reject(new Error('Failed to start recognition: ' + e.message));
|
||||
}
|
||||
});
|
||||
},
|
||||
|
||||
stopListening: function() {
|
||||
if (_recognition && _isListening) {
|
||||
_recognition.stop();
|
||||
}
|
||||
},
|
||||
|
||||
isListening: function() {
|
||||
return _isListening;
|
||||
},
|
||||
|
||||
getCurrentTranscript: function() {
|
||||
return _transcript.trim();
|
||||
},
|
||||
|
||||
// Check if browser likely sends to cloud
|
||||
getPrivacyInfo: function() {
|
||||
var isChrome = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
|
||||
var isEdge = /Edg/.test(navigator.userAgent);
|
||||
|
||||
if (isChrome || isEdge) {
|
||||
return {
|
||||
provider: 'Google Cloud Speech',
|
||||
privacy: 'Audio sent to Google servers',
|
||||
warning: 'NOT HIPAA-compliant'
|
||||
};
|
||||
} else if (/Safari/.test(navigator.userAgent) && !/Chrome/.test(navigator.userAgent)) {
|
||||
return {
|
||||
provider: 'Apple Speech Recognition',
|
||||
privacy: 'May process on-device or Apple servers',
|
||||
warning: 'Check Apple privacy policy'
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
provider: 'Unknown',
|
||||
privacy: 'May send audio to cloud servers',
|
||||
warning: 'Privacy unknown'
|
||||
};
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
})();
|
||||
166
public/js/transcriptionSettings.js
Normal file
166
public/js/transcriptionSettings.js
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
// ============================================================
|
||||
// TRANSCRIPTION SETTINGS — Browser Whisper + Web Speech API
|
||||
// ============================================================
|
||||
|
||||
(function() {
|
||||
var _inited = false;
|
||||
|
||||
document.addEventListener('tabChanged', function(e) {
|
||||
if (e.detail.tab !== 'settings' || _inited) return;
|
||||
_inited = true;
|
||||
initTranscriptionSettings();
|
||||
});
|
||||
|
||||
// Also init on direct page load
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
setTimeout(function() {
|
||||
if (!_inited && document.getElementById('browser-whisper-enabled')) {
|
||||
_inited = true;
|
||||
initTranscriptionSettings();
|
||||
}
|
||||
}, 500);
|
||||
});
|
||||
} else {
|
||||
setTimeout(function() {
|
||||
if (!_inited && document.getElementById('browser-whisper-enabled')) {
|
||||
_inited = true;
|
||||
initTranscriptionSettings();
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function initTranscriptionSettings() {
|
||||
console.log('[TranscriptionSettings] Initializing...');
|
||||
|
||||
// ── Browser Whisper ──────────────────────────────────────
|
||||
var whisperCheckbox = document.getElementById('browser-whisper-enabled');
|
||||
var whisperStatus = document.getElementById('browser-whisper-status');
|
||||
var whisperModel = document.getElementById('browser-whisper-model');
|
||||
var whisperPreload = document.getElementById('btn-whisper-preload');
|
||||
var whisperProgress = document.getElementById('browser-whisper-progress');
|
||||
var whisperProgressText = document.getElementById('browser-whisper-progress-text');
|
||||
|
||||
if (whisperCheckbox && window.BrowserWhisper) {
|
||||
whisperCheckbox.checked = BrowserWhisper.isEnabled();
|
||||
whisperStatus.textContent = BrowserWhisper.isEnabled() ? 'On — audio stays on device' : 'Off';
|
||||
|
||||
if (whisperModel) {
|
||||
whisperModel.value = BrowserWhisper.getModel();
|
||||
}
|
||||
|
||||
whisperCheckbox.addEventListener('change', function() {
|
||||
BrowserWhisper.setEnabled(whisperCheckbox.checked);
|
||||
whisperStatus.textContent = whisperCheckbox.checked ? 'On — audio stays on device' : 'Off';
|
||||
|
||||
if (whisperCheckbox.checked) {
|
||||
// Auto-preload on enable
|
||||
BrowserWhisper.preload(function(file, pct) {
|
||||
if (whisperProgress && whisperProgressText) {
|
||||
if (pct >= 100) {
|
||||
whisperProgress.style.display = 'none';
|
||||
return;
|
||||
}
|
||||
whisperProgress.style.display = 'block';
|
||||
whisperProgressText.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
if (whisperModel) {
|
||||
whisperModel.addEventListener('change', function() {
|
||||
BrowserWhisper.setModel(whisperModel.value);
|
||||
});
|
||||
}
|
||||
|
||||
if (whisperPreload) {
|
||||
whisperPreload.addEventListener('click', function() {
|
||||
if (!BrowserWhisper || !BrowserWhisper.isSupported()) {
|
||||
showToast('Browser Whisper not supported', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
whisperPreload.disabled = true;
|
||||
whisperPreload.innerHTML = '<i class="fas fa-spinner fa-spin"></i> Downloading...';
|
||||
whisperProgress.style.display = 'block';
|
||||
whisperProgressText.textContent = 'Initializing...';
|
||||
|
||||
BrowserWhisper.setEnabled(true);
|
||||
whisperCheckbox.checked = true;
|
||||
whisperStatus.textContent = 'On — audio stays on device';
|
||||
|
||||
BrowserWhisper.preload(function(file, pct) {
|
||||
if (pct >= 100) {
|
||||
whisperProgress.style.display = 'none';
|
||||
whisperPreload.disabled = false;
|
||||
whisperPreload.innerHTML = '<i class="fas fa-download"></i> Pre-download model';
|
||||
showToast('Whisper model ready!', 'success');
|
||||
return;
|
||||
}
|
||||
whisperProgress.style.display = 'block';
|
||||
whisperProgressText.textContent = file + (pct > 0 ? ' ' + pct + '%' : '');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// ── Web Speech Recognition ───────────────────────────────
|
||||
var speechCheckbox = document.getElementById('web-speech-enabled');
|
||||
var speechStatus = document.getElementById('web-speech-status');
|
||||
var speechBrowserInfo = document.getElementById('web-speech-browser-info');
|
||||
|
||||
if (speechCheckbox && window.WebSpeechRecognition) {
|
||||
speechCheckbox.checked = WebSpeechRecognition.isEnabled();
|
||||
speechStatus.textContent = WebSpeechRecognition.isEnabled() ? 'On — real-time streaming' : 'Off';
|
||||
|
||||
// Show privacy info
|
||||
if (speechBrowserInfo) {
|
||||
var privacyInfo = WebSpeechRecognition.getPrivacyInfo();
|
||||
speechBrowserInfo.innerHTML =
|
||||
'<strong>Provider:</strong> ' + privacyInfo.provider + '<br>' +
|
||||
'<strong>Privacy:</strong> ' + privacyInfo.privacy + '<br>' +
|
||||
'<strong style="color:var(--orange);">⚠️ ' + privacyInfo.warning + '</strong>';
|
||||
}
|
||||
|
||||
speechCheckbox.addEventListener('change', function() {
|
||||
if (speechCheckbox.checked) {
|
||||
// Show confirmation for privacy warning
|
||||
if (!confirm(
|
||||
'WARNING: Real-time streaming uses your browser\'s speech recognition, ' +
|
||||
'which may send audio to cloud servers (e.g., Google).\n\n' +
|
||||
'This is NOT HIPAA-compliant.\n\n' +
|
||||
'Only enable if you understand and accept this privacy trade-off.\n\n' +
|
||||
'Continue?'
|
||||
)) {
|
||||
speechCheckbox.checked = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable Browser Whisper if enabling Web Speech
|
||||
if (whisperCheckbox && whisperCheckbox.checked) {
|
||||
whisperCheckbox.checked = false;
|
||||
BrowserWhisper.setEnabled(false);
|
||||
whisperStatus.textContent = 'Off';
|
||||
showToast('Browser Whisper disabled (Web Speech takes priority)', 'info');
|
||||
}
|
||||
}
|
||||
|
||||
WebSpeechRecognition.setEnabled(speechCheckbox.checked);
|
||||
speechStatus.textContent = speechCheckbox.checked ? 'On — real-time streaming' : 'Off';
|
||||
});
|
||||
|
||||
// If not supported, disable and show message
|
||||
if (!WebSpeechRecognition.isSupported()) {
|
||||
speechCheckbox.disabled = true;
|
||||
speechStatus.textContent = 'Not supported in this browser';
|
||||
if (speechBrowserInfo) {
|
||||
speechBrowserInfo.innerHTML = '<strong style="color:var(--red);">Not supported</strong> - Web Speech API not available in this browser.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
console.log('✅ Transcription settings initialized');
|
||||
}
|
||||
|
||||
})();
|
||||
Loading…
Reference in a new issue