|
<!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/[email protected]/dist/css/bootstrap.min.css" rel="stylesheet"> |
|
|
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/[email protected]/font/bootstrap-icons.css"> |
|
<style> |
|
:root { |
|
--primary: #5046e5; |
|
--primary-dark: #3730a3; |
|
--accent: #06b6d4; |
|
--accent-light: #22d3ee; |
|
--bg-color: #f8fafc; |
|
--card-bg: #ffffff; |
|
--dark-text: #0f172a; |
|
--light-text: #f8fafc; |
|
--message-bg-user: #7c3aed; |
|
--message-bg-assistant: #e2e8f0; |
|
--border-radius: 16px; |
|
} |
|
|
|
body { |
|
background-color: var(--bg-color); |
|
font-family: 'Inter', 'Segoe UI', sans-serif; |
|
color: var(--dark-text); |
|
min-height: 100vh; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
} |
|
|
|
.app-container { |
|
max-width: 900px; |
|
margin: 20px auto; |
|
background: var(--card-bg); |
|
border-radius: var(--border-radius); |
|
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.05), 0 0 1px rgba(0, 0, 0, 0.1); |
|
overflow: hidden; |
|
display: grid; |
|
grid-template-rows: auto 1fr auto; |
|
height: 85vh; |
|
width: 100%; |
|
} |
|
|
|
.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.05); |
|
display: flex; |
|
align-items: center; |
|
gap: 15px; |
|
} |
|
|
|
.app-header h1 { |
|
font-size: 1.5rem; |
|
font-weight: 600; |
|
margin: 0; |
|
} |
|
|
|
.app-title-icon { |
|
font-size: 1.8rem; |
|
display: flex; |
|
align-items: center; |
|
animation: pulse 2s infinite alternate; |
|
} |
|
|
|
.conversation-area { |
|
padding: 20px; |
|
overflow-y: auto; |
|
display: flex; |
|
flex-direction: column; |
|
gap: 16px; |
|
background-color: var(--bg-color); |
|
height: 100%; |
|
} |
|
|
|
.message-group { |
|
display: flex; |
|
flex-direction: column; |
|
gap: 8px; |
|
max-width: 85%; |
|
} |
|
|
|
.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; |
|
} |
|
|
|
.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: 32px; |
|
height: 32px; |
|
border-radius: 50%; |
|
display: flex; |
|
align-items: center; |
|
justify-content: center; |
|
font-size: 14px; |
|
margin-bottom: 6px; |
|
} |
|
|
|
.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: 24px; |
|
background-color: white; |
|
border-top: 1px solid rgba(0, 0, 0, 0.05); |
|
display: flex; |
|
justify-content: center; |
|
align-items: center; |
|
} |
|
|
|
.listen-container { |
|
display: flex; |
|
flex-direction: column; |
|
align-items: center; |
|
} |
|
|
|
.listen-ball { |
|
width: 100px; |
|
height: 100px; |
|
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 4px 16px rgba(80, 70, 229, 0.4); |
|
position: relative; |
|
overflow: hidden; |
|
} |
|
|
|
.listen-ball.listening { |
|
background: linear-gradient(135deg, var(--accent), var(--accent-light)); |
|
animation: pulse 1.5s infinite; |
|
} |
|
|
|
.listen-ball.processing { |
|
background: linear-gradient(135deg, #8b5cf6, #7c3aed); |
|
animation: none; |
|
} |
|
|
|
.listen-ball i { |
|
font-size: 2.5rem; |
|
} |
|
|
|
.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: rgba(0, 0, 0, 0.04); |
|
border-radius: 50px; |
|
padding: 8px 16px; |
|
font-size: 0.9rem; |
|
color: var(--dark-text); |
|
margin-top: 16px; |
|
display: inline-flex; |
|
align-items: center; |
|
gap: 8px; |
|
font-weight: 500; |
|
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05); |
|
} |
|
|
|
.status-badge i { |
|
font-size: 1rem; |
|
} |
|
|
|
.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: 3.5rem; |
|
margin-bottom: 20px; |
|
color: #cbd5e1; |
|
} |
|
|
|
.empty-state h3 { |
|
font-size: 1.3rem; |
|
margin-bottom: 10px; |
|
color: #64748b; |
|
} |
|
|
|
.time-stamp { |
|
font-size: 0.75rem; |
|
margin-top: 4px; |
|
opacity: 0.7; |
|
align-self: flex-end; |
|
} |
|
|
|
@keyframes pulse { |
|
0% { transform: scale(1); } |
|
50% { transform: scale(1.05); } |
|
100% { transform: scale(1); } |
|
} |
|
|
|
@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: 80px; |
|
height: 80px; |
|
} |
|
|
|
.listen-ball i { |
|
font-size: 2rem; |
|
} |
|
} |
|
</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/[email protected]/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 lastAudioLevel = 0; |
|
let currentUserGroup = null; |
|
let currentAssistantGroup = null; |
|
|
|
|
|
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 |
|
}); |
|
} |
|
|
|
function startAnalyzingAudioLevels() { |
|
if (!audioStream) return; |
|
|
|
const audioContext = new AudioContext(); |
|
const source = audioContext.createMediaStreamSource(audioStream); |
|
const analyzer = audioContext.createAnalyser(); |
|
analyzer.fftSize = 256; |
|
source.connect(analyzer); |
|
|
|
const bufferLength = analyzer.frequencyBinCount; |
|
const dataArray = new Uint8Array(bufferLength); |
|
|
|
let silenceCounter = 0; |
|
const SILENCE_THRESHOLD = 10; |
|
const MAX_SILENCE_COUNT = 20; |
|
|
|
clearInterval(silenceDetectionInterval); |
|
silenceDetectionInterval = setInterval(() => { |
|
analyzer.getByteFrequencyData(dataArray); |
|
|
|
|
|
let sum = 0; |
|
for (let i = 0; i < bufferLength; i++) { |
|
sum += dataArray[i]; |
|
} |
|
const avg = sum / bufferLength; |
|
lastAudioLevel = avg; |
|
|
|
|
|
if (avg > SILENCE_THRESHOLD) { |
|
silenceCounter = 0; |
|
if (!isListening && isAutoListening) { |
|
startListening(); |
|
} |
|
} else { |
|
silenceCounter++; |
|
if (silenceCounter >= MAX_SILENCE_COUNT && isListening) { |
|
stopListening(); |
|
} |
|
} |
|
}, 100); |
|
} |
|
|
|
async function startListening() { |
|
if (isListening) return; |
|
|
|
try { |
|
|
|
if (audioStream) { |
|
audioStream.getTracks().forEach(track => track.stop()); |
|
} |
|
|
|
audioStream = await navigator.mediaDevices.getUserMedia({ audio: true }); |
|
mediaRecorder = new MediaRecorder(audioStream, { mimeType: "audio/webm" }); |
|
|
|
mediaRecorder.ondataavailable = event => audioChunks.push(event.data); |
|
|
|
mediaRecorder.onstop = async () => { |
|
if (audioChunks.length === 0) { |
|
updateState("idle"); |
|
return; |
|
} |
|
|
|
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("Ready for next sound input", "bi-broadcast"); |
|
|
|
|
|
if (isAutoListening) { |
|
setTimeout(() => { |
|
startAnalyzingAudioLevels(); |
|
}, 1000); |
|
} |
|
} 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"); |
|
} |
|
}; |
|
|
|
audioChunks = []; |
|
mediaRecorder.start(); |
|
|
|
updateState("listening"); |
|
updateStatus("Listening...", "bi-ear"); |
|
|
|
|
|
setTimeout(() => { |
|
if (mediaRecorder && mediaRecorder.state === "recording") { |
|
stopListening(); |
|
} |
|
}, 8000); |
|
|
|
isListening = true; |
|
} catch (error) { |
|
console.error("Error accessing microphone:", error); |
|
updateStatus("Microphone access denied", "bi-mic-mute"); |
|
updateState("idle"); |
|
} |
|
} |
|
|
|
function stopListening() { |
|
if (!isListening) return; |
|
|
|
if (mediaRecorder && mediaRecorder.state === "recording") { |
|
mediaRecorder.stop(); |
|
} |
|
|
|
isListening = false; |
|
} |
|
|
|
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>`; |
|
} |
|
} |
|
|
|
function toggleContinuousListening() { |
|
isAutoListening = !isAutoListening; |
|
|
|
if (isAutoListening) { |
|
updateStatus("Auto-listening mode active", "bi-broadcast"); |
|
startAnalyzingAudioLevels(); |
|
} else { |
|
updateStatus("Tap to listen", "bi-info-circle"); |
|
clearInterval(silenceDetectionInterval); |
|
} |
|
} |
|
|
|
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 (isListening) { |
|
stopListening(); |
|
} else if (isAutoListening) { |
|
toggleContinuousListening(); |
|
} else { |
|
startListening(); |
|
} |
|
}); |
|
|
|
listenBall.addEventListener("dblclick", toggleContinuousListening); |
|
|
|
audioPlayer.addEventListener("ended", () => { |
|
if (isAutoListening) { |
|
setTimeout(() => { |
|
startAnalyzingAudioLevels(); |
|
}, 500); |
|
} |
|
}); |
|
|
|
|
|
updateStatus("Tap to listen", "bi-info-circle"); |
|
</script> |
|
</body> |
|
</html> |