| | <!DOCTYPE html> |
| | <html lang="en"> |
| | <head> |
| | <meta charset="UTF-8"> |
| | <meta name="viewport" content="width=device-width, initial-scale=1.0"> |
| | <title>Sound Chat Interface</title> |
| | |
| | <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet"> |
| | |
| | <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.0/font/bootstrap-icons.css"> |
| | |
| | <link href="https://fonts.googleapis.com/css2?family=Poppins:wght@300;400;500;600;700&display=swap" rel="stylesheet"> |
| | <style> |
| | :root { |
| | --primary: #2dd4bf; |
| | --primary-dark: #0d9488; |
| | --accent: #f59e0b; |
| | --accent-light: #fbbf24; |
| | --bg-color: #f0f9ff; |
| | --card-bg: #ffffff; |
| | --dark-text: #0f172a; |
| | --light-text: #f8fafc; |
| | --message-bg-user: #2dd4bf; |
| | --message-bg-assistant: #e2e8f0; |
| | --border-radius: 20px; |
| | --shadow: 0 10px 25px rgba(0, 0, 0, 0.05), 0 0 1px rgba(0, 0, 0, 0.1); |
| | } |
| | |
| | body { |
| | background-color: var(--bg-color); |
| | font-family: 'Poppins', 'Segoe UI', sans-serif; |
| | color: var(--dark-text); |
| | min-height: 100vh; |
| | display: flex; |
| | align-items: center; |
| | justify-content: center; |
| | background-image: radial-gradient(circle at 10% 20%, rgba(45, 212, 191, 0.1) 0%, rgba(45, 212, 191, 0.03) 50%); |
| | } |
| | |
| | .app-container { |
| | max-width: 900px; |
| | margin: 20px auto; |
| | background: var(--card-bg); |
| | border-radius: var(--border-radius); |
| | box-shadow: var(--shadow); |
| | overflow: hidden; |
| | display: grid; |
| | grid-template-rows: auto 1fr auto; |
| | height: 85vh; |
| | width: 100%; |
| | border: 1px solid rgba(45, 212, 191, 0.1); |
| | } |
| | |
| | .app-header { |
| | padding: 24px; |
| | background: linear-gradient(135deg, var(--primary), var(--primary-dark)); |
| | color: var(--light-text); |
| | border-bottom: 1px solid rgba(255, 255, 255, 0.1); |
| | display: flex; |
| | align-items: center; |
| | gap: 15px; |
| | position: relative; |
| | overflow: hidden; |
| | } |
| | |
| | .app-header::before { |
| | content: ''; |
| | position: absolute; |
| | top: 0; |
| | left: 0; |
| | right: 0; |
| | bottom: 0; |
| | background-image: url("data:image/svg+xml,%3Csvg width='100' height='100' viewBox='0 0 100 100' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M11 18c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm48 25c3.866 0 7-3.134 7-7s-3.134-7-7-7-7 3.134-7 7 3.134 7 7 7zm-43-7c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm63 31c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM34 90c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zm56-76c1.657 0 3-1.343 3-3s-1.343-3-3-3-3 1.343-3 3 1.343 3 3 3zM12 86c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm28-65c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm23-11c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-6 60c2.21 0 4-1.79 4-4s-1.79-4-4-4-4 1.79-4 4 1.79 4 4 4zm29 22c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zM32 63c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm57-13c2.76 0 5-2.24 5-5s-2.24-5-5-5-5 2.24-5 5 2.24 5 5 5zm-9-21c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM60 91c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM35 41c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2zM12 60c1.105 0 2-.895 2-2s-.895-2-2-2-2 .895-2 2 .895 2 2 2z' fill='rgba(255,255,255,0.1)' fill-rule='evenodd'/%3E%3C/svg%3E"); |
| | opacity: 0.5; |
| | z-index: 0; |
| | } |
| | |
| | .app-header h1 { |
| | font-size: 1.6rem; |
| | font-weight: 600; |
| | margin: 0; |
| | position: relative; |
| | z-index: 1; |
| | letter-spacing: 0.5px; |
| | } |
| | |
| | .app-title-icon { |
| | font-size: 1.8rem; |
| | display: flex; |
| | align-items: center; |
| | animation: pulse 2s infinite alternate; |
| | position: relative; |
| | z-index: 1; |
| | background: rgba(255, 255, 255, 0.2); |
| | border-radius: 50%; |
| | width: 40px; |
| | height: 40px; |
| | justify-content: center; |
| | } |
| | |
| | .conversation-area { |
| | padding: 24px; |
| | overflow-y: auto; |
| | display: flex; |
| | flex-direction: column; |
| | gap: 20px; |
| | background-color: var(--bg-color); |
| | height: 100%; |
| | background-image: linear-gradient(rgba(45, 212, 191, 0.03) 1px, transparent 1px), |
| | linear-gradient(90deg, rgba(45, 212, 191, 0.03) 1px, transparent 1px); |
| | background-size: 20px 20px; |
| | } |
| | |
| | .conversation-area::-webkit-scrollbar { |
| | width: 8px; |
| | } |
| | |
| | .conversation-area::-webkit-scrollbar-track { |
| | background: transparent; |
| | } |
| | |
| | .conversation-area::-webkit-scrollbar-thumb { |
| | background-color: rgba(45, 212, 191, 0.3); |
| | border-radius: 20px; |
| | } |
| | |
| | .message-group { |
| | display: flex; |
| | flex-direction: column; |
| | gap: 8px; |
| | max-width: 80%; |
| | } |
| | |
| | .message-group.user { |
| | align-self: flex-end; |
| | } |
| | |
| | .message { |
| | padding: 16px; |
| | border-radius: 18px; |
| | position: relative; |
| | line-height: 1.5; |
| | box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05); |
| | animation: fadeIn 0.3s ease-out; |
| | transition: transform 0.2s ease; |
| | } |
| | |
| | .message:hover { |
| | transform: translateY(-2px); |
| | } |
| | |
| | .message.user { |
| | background-color: var(--message-bg-user); |
| | color: var(--light-text); |
| | border-top-right-radius: 4px; |
| | } |
| | |
| | .message.assistant { |
| | background-color: var(--message-bg-assistant); |
| | color: var(--dark-text); |
| | border-top-left-radius: 4px; |
| | } |
| | |
| | .message-avatar { |
| | width: 36px; |
| | height: 36px; |
| | border-radius: 50%; |
| | display: flex; |
| | align-items: center; |
| | justify-content: center; |
| | font-size: 14px; |
| | margin-bottom: 6px; |
| | box-shadow: 0 3px 6px rgba(0, 0, 0, 0.1); |
| | } |
| | |
| | .user .message-avatar { |
| | background-color: var(--primary-dark); |
| | color: var(--light-text); |
| | align-self: flex-end; |
| | } |
| | |
| | .assistant .message-avatar { |
| | background-color: var(--accent); |
| | color: var(--light-text); |
| | } |
| | |
| | .controls-area { |
| | padding: 28px; |
| | background-color: white; |
| | border-top: 1px solid rgba(0, 0, 0, 0.05); |
| | display: flex; |
| | justify-content: center; |
| | align-items: center; |
| | position: relative; |
| | } |
| | |
| | .controls-area::before { |
| | content: ''; |
| | position: absolute; |
| | top: 0; |
| | left: 0; |
| | right: 0; |
| | height: 4px; |
| | background: linear-gradient(90deg, var(--primary), var(--accent)); |
| | opacity: 0.6; |
| | } |
| | |
| | .listen-container { |
| | display: flex; |
| | flex-direction: column; |
| | align-items: center; |
| | } |
| | |
| | .listen-ball { |
| | width: 110px; |
| | height: 110px; |
| | border-radius: 50%; |
| | background: linear-gradient(135deg, var(--primary), var(--primary-dark)); |
| | color: white; |
| | display: flex; |
| | align-items: center; |
| | justify-content: center; |
| | cursor: pointer; |
| | transition: all 0.3s ease; |
| | box-shadow: 0 6px 20px rgba(45, 212, 191, 0.4); |
| | position: relative; |
| | overflow: hidden; |
| | } |
| | |
| | .listen-ball::before { |
| | content: ''; |
| | position: absolute; |
| | width: 150%; |
| | height: 150%; |
| | background: radial-gradient(circle, rgba(255,255,255,0.2) 0%, rgba(255,255,255,0) 70%); |
| | top: -25%; |
| | left: -25%; |
| | } |
| | |
| | .listen-ball.listening { |
| | background: linear-gradient(135deg, var(--accent), var(--accent-light)); |
| | animation: pulse 1.5s infinite; |
| | } |
| | |
| | .listen-ball.processing { |
| | background: linear-gradient(135deg, var(--accent-light), var(--accent)); |
| | animation: rotate 2s linear infinite; |
| | } |
| | |
| | .listen-ball i { |
| | font-size: 2.8rem; |
| | filter: drop-shadow(0 2px 4px rgba(0, 0, 0, 0.2)); |
| | } |
| | |
| | .sound-wave { |
| | position: absolute; |
| | top: 0; |
| | left: 0; |
| | width: 100%; |
| | height: 100%; |
| | border-radius: 50%; |
| | opacity: 0; |
| | } |
| | |
| | .listening .sound-wave { |
| | border: 2px solid rgba(255, 255, 255, 0.5); |
| | animation: wave 2s infinite; |
| | } |
| | |
| | .status-badge { |
| | background-color: white; |
| | border-radius: 50px; |
| | padding: 10px 20px; |
| | font-size: 0.9rem; |
| | color: var(--dark-text); |
| | margin-top: 18px; |
| | display: inline-flex; |
| | align-items: center; |
| | gap: 10px; |
| | font-weight: 500; |
| | box-shadow: 0 4px 10px rgba(0, 0, 0, 0.08); |
| | border: 1px solid rgba(45, 212, 191, 0.2); |
| | transition: all 0.3s ease; |
| | } |
| | |
| | .status-badge:hover { |
| | transform: translateY(-2px); |
| | box-shadow: 0 6px 15px rgba(0, 0, 0, 0.1); |
| | } |
| | |
| | .status-badge i { |
| | font-size: 1.1rem; |
| | color: var(--primary); |
| | } |
| | |
| | .audio-controls { |
| | display: none; |
| | } |
| | |
| | .empty-state { |
| | display: flex; |
| | flex-direction: column; |
| | align-items: center; |
| | justify-content: center; |
| | height: 100%; |
| | color: #94a3b8; |
| | text-align: center; |
| | padding: 20px; |
| | } |
| | |
| | .empty-state i { |
| | font-size: 4rem; |
| | margin-bottom: 20px; |
| | color: var(--primary); |
| | opacity: 0.7; |
| | } |
| | |
| | .empty-state h3 { |
| | font-size: 1.5rem; |
| | margin-bottom: 12px; |
| | color: #475569; |
| | font-weight: 600; |
| | } |
| | |
| | .empty-state p { |
| | color: #64748b; |
| | font-size: 1.1rem; |
| | max-width: 260px; |
| | line-height: 1.6; |
| | } |
| | |
| | .time-stamp { |
| | font-size: 0.75rem; |
| | margin-top: 4px; |
| | opacity: 0.7; |
| | align-self: flex-end; |
| | } |
| | |
| | @keyframes pulse { |
| | 0% { transform: scale(1); box-shadow: 0 6px 20px rgba(45, 212, 191, 0.4); } |
| | 50% { transform: scale(1.05); box-shadow: 0 8px 25px rgba(45, 212, 191, 0.6); } |
| | 100% { transform: scale(1); box-shadow: 0 6px 20px rgba(45, 212, 191, 0.4); } |
| | } |
| | |
| | @keyframes rotate { |
| | from { transform: rotate(0deg); } |
| | to { transform: rotate(360deg); } |
| | } |
| | |
| | @keyframes wave { |
| | 0% { transform: scale(1); opacity: 0.7; } |
| | 100% { transform: scale(1.5); opacity: 0; } |
| | } |
| | |
| | @keyframes fadeIn { |
| | from { opacity: 0; transform: translateY(10px); } |
| | to { opacity: 1; transform: translateY(0); } |
| | } |
| | |
| | @media (max-width: 768px) { |
| | .app-container { |
| | margin: 0; |
| | height: 100vh; |
| | border-radius: 0; |
| | } |
| | |
| | .message-group { |
| | max-width: 90%; |
| | } |
| | |
| | .listen-ball { |
| | width: 90px; |
| | height: 90px; |
| | } |
| | |
| | .listen-ball i { |
| | font-size: 2.2rem; |
| | } |
| | |
| | .app-header h1 { |
| | font-size: 1.4rem; |
| | } |
| | |
| | .controls-area { |
| | padding: 20px; |
| | } |
| | |
| | .status-badge { |
| | padding: 8px 16px; |
| | font-size: 0.85rem; |
| | } |
| | } |
| | |
| | @media (min-width: 1200px) { |
| | .app-container { |
| | max-width: 1000px; |
| | } |
| | } |
| | </style> |
| | </head> |
| | <body> |
| | <div class="container-fluid p-0"> |
| | <div class="app-container"> |
| | <div class="app-header"> |
| | <div class="app-title-icon"> |
| | <i class="bi bi-soundwave"></i> |
| | </div> |
| | <h1>Sound Chat</h1> |
| | </div> |
| | |
| | <div class="conversation-area" id="conversationArea"> |
| | <div class="empty-state" id="emptyState"> |
| | <i class="bi bi-ear"></i> |
| | <h3>No messages yet</h3> |
| | <p>Tap the sound button below to start listening and chatting.</p> |
| | </div> |
| | |
| | </div> |
| | |
| | <div class="controls-area"> |
| | <div class="listen-container"> |
| | <div class="listen-ball" id="listenBall"> |
| | <div class="sound-wave"></div> |
| | <div class="sound-wave" style="animation-delay: 0.5s"></div> |
| | <div class="sound-wave" style="animation-delay: 1s"></div> |
| | <i class="bi bi-soundwave"></i> |
| | </div> |
| | <div class="status-badge" id="statusBadge"> |
| | <i class="bi bi-info-circle"></i> |
| | <span id="statusMessage">Tap to listen for sound</span> |
| | </div> |
| | </div> |
| | |
| | <div class="audio-controls"> |
| | <audio id="audioPlayer" controls></audio> |
| | </div> |
| | </div> |
| | </div> |
| | </div> |
| |
|
| |
|
| | |
| | <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script> |
| | <script> |
| | |
| | const listenBall = document.getElementById("listenBall"); |
| | const statusMessage = document.getElementById("statusMessage"); |
| | const audioPlayer = document.getElementById("audioPlayer"); |
| | const conversationArea = document.getElementById("conversationArea"); |
| | const emptyState = document.getElementById("emptyState"); |
| | const statusBadge = document.getElementById("statusBadge"); |
| | |
| | |
| | let mediaRecorder; |
| | let audioChunks = []; |
| | let audioStream; |
| | let chatHistory = []; |
| | let isListening = false; |
| | let isAutoListening = false; |
| | let silenceDetectionInterval; |
| | let activityDetectionInterval; |
| | let lastAudioLevel = 0; |
| | let silenceCounter = 0; |
| | let activityCounter = 0; |
| | let currentUserGroup = null; |
| | let currentAssistantGroup = null; |
| | let audioContext; |
| | let analyzer; |
| | let isProcessing = false; |
| | |
| | |
| | const SILENCE_THRESHOLD = 15; |
| | const ACTIVITY_THRESHOLD = 20; |
| | const MIN_ACTIVITY_DURATION = 5; |
| | const MAX_SILENCE_DURATION = 15; |
| | const MAX_RECORDING_DURATION = 8000; |
| | const COOLDOWN_PERIOD = 1000; |
| | |
| | |
| | function updateStatus(message, icon = "bi-info-circle") { |
| | statusMessage.textContent = message; |
| | statusBadge.querySelector("i").className = `bi ${icon}`; |
| | } |
| | |
| | function addMessageToChat(content, sender) { |
| | |
| | if (!emptyState.classList.contains("d-none")) { |
| | emptyState.classList.add("d-none"); |
| | } |
| | |
| | const now = new Date(); |
| | const timeString = now.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); |
| | |
| | |
| | let messageGroup; |
| | |
| | if (sender === 'user') { |
| | if (!currentUserGroup || (currentAssistantGroup && currentAssistantGroup.classList.contains("assistant"))) { |
| | currentUserGroup = document.createElement("div"); |
| | currentUserGroup.className = "message-group user"; |
| | |
| | const avatar = document.createElement("div"); |
| | avatar.className = "message-avatar"; |
| | avatar.innerHTML = "<i class='bi bi-person'></i>"; |
| | currentUserGroup.appendChild(avatar); |
| | |
| | conversationArea.appendChild(currentUserGroup); |
| | } |
| | messageGroup = currentUserGroup; |
| | currentAssistantGroup = null; |
| | } else { |
| | if (!currentAssistantGroup || (currentUserGroup && currentUserGroup.classList.contains("user"))) { |
| | currentAssistantGroup = document.createElement("div"); |
| | currentAssistantGroup.className = "message-group assistant"; |
| | |
| | const avatar = document.createElement("div"); |
| | avatar.className = "message-avatar"; |
| | avatar.innerHTML = "<i class='bi bi-robot'></i>"; |
| | currentAssistantGroup.appendChild(avatar); |
| | |
| | conversationArea.appendChild(currentAssistantGroup); |
| | } |
| | messageGroup = currentAssistantGroup; |
| | currentUserGroup = null; |
| | } |
| | |
| | |
| | const messageDiv = document.createElement("div"); |
| | messageDiv.className = `message ${sender}`; |
| | messageDiv.textContent = content; |
| | |
| | const timestamp = document.createElement("div"); |
| | timestamp.className = "time-stamp"; |
| | timestamp.textContent = timeString; |
| | |
| | messageGroup.appendChild(messageDiv); |
| | messageGroup.appendChild(timestamp); |
| | |
| | |
| | conversationArea.scrollTop = conversationArea.scrollHeight; |
| | |
| | |
| | chatHistory.push({ |
| | role: sender === 'user' ? 'user' : 'assistant', |
| | content: content |
| | }); |
| | } |
| | |
| | async function setupAudioAnalysis() { |
| | if (audioContext) { |
| | audioContext.close(); |
| | } |
| | |
| | audioContext = new AudioContext(); |
| | const source = audioContext.createMediaStreamSource(audioStream); |
| | analyzer = audioContext.createAnalyser(); |
| | analyzer.fftSize = 256; |
| | source.connect(analyzer); |
| | |
| | const bufferLength = analyzer.frequencyBinCount; |
| | const dataArray = new Uint8Array(bufferLength); |
| | |
| | return { analyzer, dataArray, bufferLength }; |
| | } |
| | |
| | function startContinuousListening() { |
| | if (!audioStream) return; |
| | |
| | |
| | setupAudioAnalysis().then(({ analyzer, dataArray, bufferLength }) => { |
| | |
| | clearInterval(activityDetectionInterval); |
| | activityDetectionInterval = setInterval(() => { |
| | if (isProcessing) return; |
| | |
| | analyzer.getByteFrequencyData(dataArray); |
| | |
| | |
| | let sum = 0; |
| | for (let i = 0; i < bufferLength; i++) { |
| | sum += dataArray[i]; |
| | } |
| | const avg = sum / bufferLength; |
| | lastAudioLevel = avg; |
| | |
| | |
| | if (avg > ACTIVITY_THRESHOLD) { |
| | activityCounter++; |
| | silenceCounter = 0; |
| | |
| | |
| | if (activityCounter >= MIN_ACTIVITY_DURATION && !isListening && !isProcessing) { |
| | startRecording(); |
| | } |
| | } else { |
| | activityCounter = 0; |
| | } |
| | }, 100); |
| | }); |
| | } |
| | |
| | function monitorSilenceDuringRecording() { |
| | if (!audioStream || !isListening) return; |
| | |
| | clearInterval(silenceDetectionInterval); |
| | silenceDetectionInterval = setInterval(() => { |
| | if (!isListening) { |
| | clearInterval(silenceDetectionInterval); |
| | return; |
| | } |
| | |
| | analyzer.getByteFrequencyData(new Uint8Array(analyzer.frequencyBinCount)); |
| | |
| | |
| | let sum = 0; |
| | for (let i = 0; i < analyzer.frequencyBinCount; i++) { |
| | sum += dataArray[i]; |
| | } |
| | const avg = sum / analyzer.frequencyBinCount; |
| | |
| | |
| | if (avg < SILENCE_THRESHOLD) { |
| | silenceCounter++; |
| | if (silenceCounter >= MAX_SILENCE_DURATION) { |
| | stopRecording(); |
| | } |
| | } else { |
| | silenceCounter = 0; |
| | } |
| | }, 100); |
| | } |
| | |
| | async function startRecording() { |
| | if (isListening || isProcessing) return; |
| | |
| | try { |
| | |
| | silenceCounter = 0; |
| | |
| | |
| | mediaRecorder = new MediaRecorder(audioStream, { mimeType: "audio/webm" }); |
| | audioChunks = []; |
| | |
| | mediaRecorder.ondataavailable = event => audioChunks.push(event.data); |
| | |
| | mediaRecorder.onstop = async () => { |
| | if (audioChunks.length === 0) { |
| | updateState("idle"); |
| | isProcessing = false; |
| | return; |
| | } |
| | |
| | isProcessing = true; |
| | updateState("processing"); |
| | |
| | try { |
| | const audioBlob = new Blob(audioChunks, { type: "audio/webm" }); |
| | const wavBlob = await convertWebMToWav(audioBlob); |
| | |
| | |
| | const formData = new FormData(); |
| | formData.append("file", wavBlob, "recording.wav"); |
| | formData.append("chat_history", JSON.stringify(chatHistory)); |
| | |
| | |
| | const response = await fetch("/continuous-chat/", { |
| | method: "POST", |
| | body: formData |
| | }); |
| | |
| | if (response.ok) { |
| | const userMessage = response.headers.get("X-User-Message") || "No user message"; |
| | const llmResponse = response.headers.get("X-LLM-Response") || "No response"; |
| | |
| | |
| | addMessageToChat(userMessage, 'user'); |
| | addMessageToChat(llmResponse, 'assistant'); |
| | |
| | |
| | const audioData = await response.blob(); |
| | audioPlayer.src = URL.createObjectURL(audioData); |
| | audioPlayer.play(); |
| | |
| | updateState("idle"); |
| | updateStatus("Listening for sound", "bi-broadcast"); |
| | } else { |
| | updateState("idle"); |
| | updateStatus("Error processing audio", "bi-exclamation-triangle"); |
| | } |
| | } catch (error) { |
| | console.error("Error:", error); |
| | updateState("idle"); |
| | updateStatus("Error processing audio", "bi-exclamation-triangle"); |
| | } |
| | |
| | |
| | setTimeout(() => { |
| | isProcessing = false; |
| | if (isAutoListening) { |
| | updateStatus("Listening for sound", "bi-broadcast"); |
| | } |
| | }, COOLDOWN_PERIOD); |
| | }; |
| | |
| | mediaRecorder.start(); |
| | isListening = true; |
| | updateState("listening"); |
| | updateStatus("Recording...", "bi-ear"); |
| | |
| | |
| | monitorSilenceDuringRecording(); |
| | |
| | |
| | setTimeout(() => { |
| | if (mediaRecorder && mediaRecorder.state === "recording") { |
| | stopRecording(); |
| | } |
| | }, MAX_RECORDING_DURATION); |
| | |
| | } catch (error) { |
| | console.error("Error starting recording:", error); |
| | updateState("idle"); |
| | updateStatus("Recording error", "bi-exclamation-triangle"); |
| | isListening = false; |
| | isProcessing = false; |
| | } |
| | } |
| | |
| | function stopRecording() { |
| | if (!isListening) return; |
| | |
| | clearInterval(silenceDetectionInterval); |
| | |
| | if (mediaRecorder && mediaRecorder.state === "recording") { |
| | mediaRecorder.stop(); |
| | } |
| | |
| | isListening = false; |
| | updateStatus("Processing...", "bi-arrow-repeat"); |
| | } |
| | |
| | function updateState(state) { |
| | listenBall.classList.remove("listening", "processing"); |
| | |
| | if (state === "listening") { |
| | listenBall.classList.add("listening"); |
| | listenBall.innerHTML = ` |
| | <div class="sound-wave"></div> |
| | <div class="sound-wave" style="animation-delay: 0.5s"></div> |
| | <div class="sound-wave" style="animation-delay: 1s"></div> |
| | <i class="bi bi-soundwave"></i> |
| | `; |
| | } else if (state === "processing") { |
| | listenBall.classList.add("processing"); |
| | listenBall.innerHTML = `<i class="bi bi-arrow-repeat"></i>`; |
| | } else { |
| | listenBall.innerHTML = `<i class="bi bi-soundwave"></i>`; |
| | } |
| | } |
| | |
| | async function toggleContinuousListening() { |
| | isAutoListening = !isAutoListening; |
| | |
| | if (isAutoListening) { |
| | try { |
| | |
| | if (!audioStream) { |
| | audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| | } |
| | |
| | updateStatus("Auto-listening active", "bi-broadcast"); |
| | startContinuousListening(); |
| | } catch (error) { |
| | console.error("Error accessing microphone:", error); |
| | updateStatus("Microphone access denied", "bi-mic-mute"); |
| | isAutoListening = false; |
| | } |
| | } else { |
| | |
| | clearInterval(activityDetectionInterval); |
| | clearInterval(silenceDetectionInterval); |
| | updateStatus("Tap to listen", "bi-info-circle"); |
| | |
| | |
| | if (isListening) { |
| | stopRecording(); |
| | } |
| | } |
| | } |
| | |
| | async function manualListening() { |
| | if (isListening || isProcessing) return; |
| | |
| | try { |
| | |
| | if (!audioStream) { |
| | audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
| | await setupAudioAnalysis(); |
| | } |
| | |
| | startRecording(); |
| | } catch (error) { |
| | console.error("Error accessing microphone:", error); |
| | updateStatus("Microphone access denied", "bi-mic-mute"); |
| | } |
| | } |
| | |
| | async function convertWebMToWav(blob) { |
| | return new Promise((resolve, reject) => { |
| | try { |
| | const reader = new FileReader(); |
| | reader.onload = function () { |
| | const audioContext = new AudioContext(); |
| | audioContext.decodeAudioData(reader.result) |
| | .then(buffer => { |
| | const wavBuffer = audioBufferToWav(buffer); |
| | resolve(new Blob([wavBuffer], { type: "audio/wav" })); |
| | }) |
| | .catch(error => { |
| | console.error("Error decoding audio data:", error); |
| | reject(error); |
| | }); |
| | }; |
| | reader.readAsArrayBuffer(blob); |
| | } catch (error) { |
| | console.error("Error in convertWebMToWav:", error); |
| | reject(error); |
| | } |
| | }); |
| | } |
| | |
| | function audioBufferToWav(buffer) { |
| | let numOfChan = buffer.numberOfChannels, |
| | length = buffer.length * numOfChan * 2 + 44, |
| | bufferArray = new ArrayBuffer(length), |
| | view = new DataView(bufferArray), |
| | channels = [], |
| | sampleRate = buffer.sampleRate, |
| | offset = 0, |
| | pos = 0; |
| | setUint32(0x46464952); |
| | setUint32(length - 8); |
| | setUint32(0x45564157); |
| | setUint32(0x20746d66); |
| | setUint32(16); |
| | setUint16(1); |
| | setUint16(numOfChan); |
| | setUint32(sampleRate); |
| | setUint32(sampleRate * 2 * numOfChan); |
| | setUint16(numOfChan * 2); |
| | setUint16(16); |
| | setUint32(0x61746164); |
| | setUint32(length - pos - 4); |
| | for (let i = 0; i < buffer.numberOfChannels; i++) |
| | channels.push(buffer.getChannelData(i)); |
| | while (pos < length) { |
| | for (let i = 0; i < numOfChan; i++) { |
| | let sample = Math.max(-1, Math.min(1, channels[i][offset])); |
| | sample = sample < 0 ? sample * 0x8000 : sample * 0x7FFF; |
| | setUint16(sample); |
| | } |
| | offset++; |
| | } |
| | function setUint16(data) { |
| | view.setUint16(pos, data, true); |
| | pos += 2; |
| | } |
| | function setUint32(data) { |
| | view.setUint32(pos, data, true); |
| | pos += 4; |
| | } |
| | return bufferArray; |
| | } |
| | |
| | |
| | listenBall.addEventListener("click", () => { |
| | if (isAutoListening) { |
| | toggleContinuousListening(); |
| | } else { |
| | if (isListening) { |
| | stopRecording(); |
| | } else { |
| | manualListening(); |
| | } |
| | } |
| | }); |
| | |
| | listenBall.addEventListener("dblclick", toggleContinuousListening); |
| | |
| | audioPlayer.addEventListener("ended", () => { |
| | if (isAutoListening && !isProcessing) { |
| | updateStatus("Listening for sound", "bi-broadcast"); |
| | } |
| | }); |
| | |
| | |
| | updateStatus("Tap to listen, double-tap for auto mode", "bi-info-circle"); |
| | |
| | |
| | window.addEventListener('beforeunload', () => { |
| | if (audioStream) { |
| | audioStream.getTracks().forEach(track => track.stop()); |
| | } |
| | if (audioContext) { |
| | audioContext.close(); |
| | } |
| | clearInterval(silenceDetectionInterval); |
| | clearInterval(activityDetectionInterval); |
| | }); |
| | </script> |
| | </body> |
| | </html> |