sinhala-chatbot / app /static /js /script.js
CHAMATH
Deploy Space with optional ASR mode
464b72a
// Global Variables
let mediaRecorder = null;
let audioChunks = [];
let isRecording = false;
let recordingStartTime = null;
let timerInterval = null;
let currentAudio = null;
let responseLanguage = 'en'; // 'en' for English only, 'si-en' for Sinhala+English
// DOM Elements - Voice Chat
const micBtn = document.getElementById('micBtn');
const statusIndicator = document.getElementById('statusIndicator');
const statusDot = statusIndicator.querySelector('.status-dot');
const statusText = statusIndicator.querySelector('.status-text');
const recordingTimer = document.getElementById('recordingTimer');
const timerText = recordingTimer.querySelector('.timer-text');
const visualizer = document.getElementById('visualizer');
const userText = document.getElementById('userText');
const botText = document.getElementById('botText');
const speakerBtn = document.getElementById('speakerBtn');
const pauseBtn = document.getElementById('pauseBtn');
const loadingOverlay = document.getElementById('loadingOverlay');
const loadingText = document.getElementById('loadingText');
const chatContainer = document.getElementById('chatContainer');
const resetBtn = document.getElementById('resetBtn');
// DOM Elements - Sections
const voiceChatSection = document.getElementById('voiceChatSection');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
checkBrowserSupport();
setupEventListeners();
});
// Check browser support for audio recording
function checkBrowserSupport() {
if (!navigator.mediaDevices || !navigator.mediaDevices.getUserMedia) {
showError('Your browser does not support audio recording. Please use a modern browser like Chrome or Firefox.');
micBtn.disabled = true;
}
}
// Setup Event Listeners
function setupEventListeners() {
micBtn.addEventListener('click', toggleRecording);
speakerBtn.addEventListener('click', playResponse);
// Pause button
if (pauseBtn) {
pauseBtn.addEventListener('click', pauseAudio);
}
// Reset button - also clears history
if (resetBtn) {
resetBtn.addEventListener('click', resetRecording);
}
}
// Toggle Recording
async function toggleRecording() {
if (isRecording) {
stopRecording();
} else {
await startRecording();
}
}
// Start Recording
async function startRecording() {
try {
const stream = await navigator.mediaDevices.getUserMedia({
audio: {
sampleRate: 16000,
channelCount: 1,
echoCancellation: true,
noiseSuppression: true
}
});
// Determine the best supported MIME type
let mimeType = 'audio/webm';
if (MediaRecorder.isTypeSupported('audio/webm;codecs=opus')) {
mimeType = 'audio/webm;codecs=opus';
} else if (MediaRecorder.isTypeSupported('audio/webm')) {
mimeType = 'audio/webm';
} else if (MediaRecorder.isTypeSupported('audio/mp4')) {
mimeType = 'audio/mp4';
} else if (MediaRecorder.isTypeSupported('audio/ogg')) {
mimeType = 'audio/ogg';
}
mediaRecorder = new MediaRecorder(stream, { mimeType });
audioChunks = [];
mediaRecorder.ondataavailable = (event) => {
if (event.data.size > 0) {
audioChunks.push(event.data);
}
};
mediaRecorder.onstop = async () => {
const audioBlob = new Blob(audioChunks, { type: mimeType });
stream.getTracks().forEach(track => track.stop());
await processAudio(audioBlob);
};
mediaRecorder.start(100); // Collect data every 100ms
isRecording = true;
recordingStartTime = Date.now();
// Update UI
updateUIForRecording(true);
startTimer();
} catch (error) {
console.error('Error starting recording:', error);
showError('Could not access microphone. Please allow microphone permission.');
}
}
// Stop Recording
function stopRecording() {
if (mediaRecorder && mediaRecorder.state !== 'inactive') {
mediaRecorder.stop();
isRecording = false;
stopTimer();
updateUIForRecording(false);
}
}
// Update UI for Recording State
function updateUIForRecording(recording) {
if (recording) {
micBtn.classList.add('recording');
statusDot.classList.add('recording');
statusText.textContent = 'Recording...';
recordingTimer.classList.add('active');
visualizer.classList.add('active');
} else {
micBtn.classList.remove('recording');
statusDot.classList.remove('recording');
statusText.textContent = 'Processing...';
recordingTimer.classList.remove('active');
visualizer.classList.remove('active');
}
}
// Timer Functions
function startTimer() {
timerInterval = setInterval(() => {
const elapsed = Date.now() - recordingStartTime;
const minutes = Math.floor(elapsed / 60000);
const seconds = Math.floor((elapsed % 60000) / 1000);
timerText.textContent = `${String(minutes).padStart(2, '0')}:${String(seconds).padStart(2, '0')}`;
}, 100);
}
function stopTimer() {
if (timerInterval) {
clearInterval(timerInterval);
timerInterval = null;
}
timerText.textContent = '00:00';
}
// Process Audio - Send to Backend
async function processAudio(audioBlob) {
showLoading('Converting speech to text...');
try {
// Convert to WAV format for better compatibility
const wavBlob = await convertToWav(audioBlob);
// Create form data
const formData = new FormData();
formData.append('audio', wavBlob, 'recording.wav');
// Send to speech-to-text endpoint
const sttResponse = await fetch('/api/speech-to-text', {
method: 'POST',
body: formData
});
if (!sttResponse.ok) {
const error = await sttResponse.json();
throw new Error(error.detail || 'Speech recognition failed');
}
const sttResult = await sttResponse.json();
const transcribedText = sttResult.text;
// Show original transcription temporarily
displayUserText(transcribedText + ' (translating...)');
// Step 2: Translate to English
showLoading('Translating to English...');
let englishText = transcribedText;
let translationSuccess = false;
try {
const translateRes = await fetch('/api/translate-to-english', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ question: transcribedText })
});
if (translateRes.ok) {
const translateData = await translateRes.json();
if (translateData.translated && translateData.english_question) {
englishText = translateData.english_question;
translationSuccess = true;
} else if (translateData.english_question && translateData.english_question !== transcribedText) {
// Even if translated flag is false, check if we got different text
englishText = translateData.english_question;
translationSuccess = true;
}
}
} catch (translateError) {
console.error('Translation error:', translateError);
}
// Display both original and English if translation succeeded, otherwise just show original
if (translationSuccess && englishText !== transcribedText) {
displayUserTextWithOriginal(transcribedText, englishText);
} else {
displayUserText(transcribedText + ' (translation failed - using original)');
}
// Step 3: Use RAG first, fallback to Gemini API
showLoading('Searching knowledge base...');
const ragResponse = await fetch('/api/rag/ask', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
question: englishText,
response_lang: responseLanguage // 'en' or 'si-en'
})
});
if (!ragResponse.ok) {
const error = await ragResponse.json();
throw new Error(error.detail || 'Query failed');
}
const ragResult = await ragResponse.json();
const botResponse = ragResult.answer;
const source = ragResult.source; // 'rag', 'gemini', or 'none'
// Display bot response with source indicator
displayBotTextWithSource(botResponse, source);
// Enable speaker button
speakerBtn.disabled = false;
// Update status
updateStatus('ready', 'Ready');
} catch (error) {
console.error('Processing error:', error);
showError(error.message);
updateStatus('ready', 'Ready');
} finally {
hideLoading();
}
}
// Convert audio blob to WAV format
async function convertToWav(audioBlob) {
return new Promise((resolve, reject) => {
const audioContext = new (window.AudioContext || window.webkitAudioContext)();
const reader = new FileReader();
reader.onload = async () => {
try {
const arrayBuffer = reader.result;
const audioBuffer = await audioContext.decodeAudioData(arrayBuffer);
// Resample to 16kHz for Whisper model
const targetSampleRate = 16000;
const offlineContext = new OfflineAudioContext(
1, // mono
audioBuffer.duration * targetSampleRate,
targetSampleRate
);
const source = offlineContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(offlineContext.destination);
source.start(0);
const renderedBuffer = await offlineContext.startRendering();
const wavBlob = audioBufferToWav(renderedBuffer);
resolve(wavBlob);
} catch (error) {
// If conversion fails, return original blob
console.warn('WAV conversion failed, using original format:', error);
resolve(audioBlob);
}
};
reader.onerror = () => reject(reader.error);
reader.readAsArrayBuffer(audioBlob);
});
}
// Convert AudioBuffer to WAV Blob
function audioBufferToWav(buffer) {
const numChannels = buffer.numberOfChannels;
const sampleRate = buffer.sampleRate;
const format = 1; // PCM
const bitDepth = 16;
const bytesPerSample = bitDepth / 8;
const blockAlign = numChannels * bytesPerSample;
const dataLength = buffer.length * blockAlign;
const bufferLength = 44 + dataLength;
const arrayBuffer = new ArrayBuffer(bufferLength);
const view = new DataView(arrayBuffer);
// WAV header
writeString(view, 0, 'RIFF');
view.setUint32(4, 36 + dataLength, true);
writeString(view, 8, 'WAVE');
writeString(view, 12, 'fmt ');
view.setUint32(16, 16, true);
view.setUint16(20, format, true);
view.setUint16(22, numChannels, true);
view.setUint32(24, sampleRate, true);
view.setUint32(28, sampleRate * blockAlign, true);
view.setUint16(32, blockAlign, true);
view.setUint16(34, bitDepth, true);
writeString(view, 36, 'data');
view.setUint32(40, dataLength, true);
// Write audio data
const channelData = buffer.getChannelData(0);
let offset = 44;
for (let i = 0; i < channelData.length; i++) {
const sample = Math.max(-1, Math.min(1, channelData[i]));
view.setInt16(offset, sample < 0 ? sample * 0x8000 : sample * 0x7FFF, true);
offset += 2;
}
return new Blob([arrayBuffer], { type: 'audio/wav' });
}
function writeString(view, offset, string) {
for (let i = 0; i < string.length; i++) {
view.setUint8(offset + i, string.charCodeAt(i));
}
}
// Display Functions
function displayUserText(text) {
userText.innerHTML = `<p>${escapeHtml(text)}</p>`;
}
function displayUserTextWithOriginal(originalText, englishText) {
userText.innerHTML = `
<p>${escapeHtml(originalText)}</p>
`;
}
function displayBotText(text) {
// Convert markdown-like formatting to HTML
const formattedText = formatText(text);
botText.innerHTML = formattedText;
}
function displayBotTextWithSource(text, source) {
// Convert markdown-like formatting to HTML with source badge
const formattedText = formatText(text);
let sourceLabel = '';
if (source === 'rag') {
sourceLabel = '<span class="source-badge source-rag"><i class="fas fa-database"></i> From Documents</span>';
} else if (source === 'gemini') {
sourceLabel = '<span class="source-badge source-gemini"><i class="fas fa-brain"></i> From AI</span>';
}
botText.innerHTML = sourceLabel + formattedText;
}
function formatText(text) {
// Basic formatting
let formatted = escapeHtml(text);
// Convert line breaks
formatted = formatted.replace(/\n/g, '<br>');
// Convert **bold** to <strong>
formatted = formatted.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
// Convert *italic* to <em>
formatted = formatted.replace(/\*(.*?)\*/g, '<em>$1</em>');
return `<p>${formatted}</p>`;
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Play Response using TTS
async function playResponse() {
const text = botText.textContent || botText.innerText;
if (!text || text.includes('will appear here')) {
return;
}
// If paused, resume
if (currentAudio && currentAudio.paused) {
currentAudio.play();
speakerBtn.classList.add('playing');
pauseBtn.classList.remove('paused');
pauseBtn.querySelector('i').className = 'fas fa-pause';
return;
}
// Stop current audio if playing
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
speakerBtn.classList.remove('playing');
}
speakerBtn.classList.add('playing');
speakerBtn.querySelector('i').className = 'fas fa-spinner fa-spin';
try {
const ttsLang = responseLanguage === 'en' ? 'en' : 'si';
const response = await fetch('/api/text-to-speech', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
text: text,
lang: ttsLang
})
});
if (!response.ok) {
throw new Error('Text-to-speech failed');
}
const audioBlob = await response.blob();
const audioUrl = URL.createObjectURL(audioBlob);
currentAudio = new Audio(audioUrl);
currentAudio.onended = () => {
speakerBtn.classList.remove('playing');
speakerBtn.querySelector('i').className = 'fas fa-volume-up';
pauseBtn.classList.remove('paused');
pauseBtn.querySelector('i').className = 'fas fa-pause';
URL.revokeObjectURL(audioUrl);
currentAudio = null;
};
currentAudio.onerror = () => {
speakerBtn.classList.remove('playing');
speakerBtn.querySelector('i').className = 'fas fa-volume-up';
showError('Failed to play audio');
};
await currentAudio.play();
speakerBtn.querySelector('i').className = 'fas fa-volume-up';
} catch (error) {
console.error('TTS error:', error);
speakerBtn.classList.remove('playing');
speakerBtn.querySelector('i').className = 'fas fa-volume-up';
showError('Text-to-speech failed');
}
}
// Pause Audio Playback
function pauseAudio() {
if (currentAudio && !currentAudio.paused) {
currentAudio.pause();
speakerBtn.classList.remove('playing');
pauseBtn.classList.add('paused');
pauseBtn.querySelector('i').className = 'fas fa-play';
} else if (currentAudio && currentAudio.paused) {
currentAudio.play();
speakerBtn.classList.add('playing');
pauseBtn.classList.remove('paused');
pauseBtn.querySelector('i').className = 'fas fa-pause';
}
}
// Reset Recording / Stop current action
function resetRecording() {
if (isRecording) {
stopRecording();
}
if (currentAudio) {
currentAudio.pause();
currentAudio = null;
speakerBtn.classList.remove('playing');
}
updateStatus('ready', 'Ready');
clearHistory();
}
// Clear Conversation History
async function clearHistory() {
try {
const response = await fetch('/api/clear-history', {
method: 'POST'
});
if (response.ok) {
// Reset UI
userText.innerHTML = '<p class="placeholder">Your transcribed message will appear here...</p>';
botText.innerHTML = '<p class="placeholder">Bot response will appear here...</p>';
speakerBtn.disabled = true;
// Show confirmation
showSuccess('Conversation history cleared');
}
} catch (error) {
console.error('Error clearing history:', error);
showError('Failed to clear history');
}
}
// Loading Functions
function showLoading(message = 'Processing...') {
loadingText.textContent = message;
loadingOverlay.classList.add('active');
}
function hideLoading() {
loadingOverlay.classList.remove('active');
}
// Status Update
function updateStatus(state, text) {
statusDot.className = 'status-dot';
if (state !== 'ready') {
statusDot.classList.add(state);
}
statusText.textContent = text;
}
// Notification Functions
function showError(message) {
// Create toast notification
showToast(message, 'error');
// Clear user and bot input fields after 2 seconds
setTimeout(() => {
if (userText) {
userText.innerHTML = '<p class="placeholder">Your transcribed message will appear here...</p>';
}
if (botText) {
botText.innerHTML = '<p class="placeholder">Bot response will appear here...</p>';
}
}, 2000);
}
function showSuccess(message) {
showToast(message, 'success');
}
function showToast(message, type = 'info') {
// Remove existing toasts
const existingToasts = document.querySelectorAll('.toast');
existingToasts.forEach(t => t.remove());
// Create toast element
const toast = document.createElement('div');
toast.className = `toast toast-${type}`;
toast.innerHTML = `
<i class="fas ${type === 'error' ? 'fa-exclamation-circle' : 'fa-check-circle'}"></i>
<span>${message}</span>
`;
// Add styles
toast.style.cssText = `
position: fixed;
bottom: 20px;
left: 50%;
transform: translateX(-50%);
padding: 12px 24px;
background: ${type === 'error' ? '#ef4444' : '#22c55e'};
color: white;
border-radius: 8px;
display: flex;
align-items: center;
gap: 10px;
z-index: 2000;
box-shadow: 0 4px 20px rgba(0,0,0,0.3);
animation: slideUp 0.3s ease;
`;
// Add animation keyframes if not exists
if (!document.getElementById('toast-styles')) {
const style = document.createElement('style');
style.id = 'toast-styles';
style.textContent = `
@keyframes slideUp {
from { transform: translateX(-50%) translateY(100%); opacity: 0; }
to { transform: translateX(-50%) translateY(0); opacity: 1; }
}
`;
document.head.appendChild(style);
}
document.body.appendChild(toast);
// Remove after 4 seconds
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 4000);
}