|
|
<!DOCTYPE html> |
|
|
<html lang="en"> |
|
|
<head> |
|
|
<meta charset="UTF-8"> |
|
|
<meta name="viewport" content="width=device-width, initial-scale=1.0"> |
|
|
<title>ASR Audio Intelligence Platform</title> |
|
|
<script src="https://cdn.tailwindcss.com"></script> |
|
|
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css"> |
|
|
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script> |
|
|
<style> |
|
|
:root { --asr-primary: #0f172a; --asr-accent: #3b82f6; --asr-success: #10b981; } |
|
|
body { font-family: 'Inter', system-ui, sans-serif; } |
|
|
.asr-gradient { background: linear-gradient(135deg, #0f172a 0%, #1e293b 50%, #334155 100%); } |
|
|
.asr-accent-gradient { background: linear-gradient(135deg, #3b82f6 0%, #8b5cf6 100%); } |
|
|
.glass-card { background: rgba(255,255,255,0.95); backdrop-filter: blur(10px); } |
|
|
.metric-card:hover { transform: translateY(-2px); box-shadow: 0 20px 40px rgba(0,0,0,0.1); } |
|
|
.upload-zone { border: 2px dashed #cbd5e1; transition: all 0.3s; } |
|
|
.upload-zone:hover, .upload-zone.dragover { border-color: #3b82f6; background: #f0f9ff; } |
|
|
.progress-ring { transform: rotate(-90deg); } |
|
|
.segment-row:hover { background: #f8fafc; } |
|
|
@keyframes pulse-ring { 0% { transform: scale(0.8); opacity: 1; } 100% { transform: scale(1.4); opacity: 0; } } |
|
|
.live-indicator::before { content: ''; position: absolute; width: 100%; height: 100%; background: #10b981; border-radius: 50%; animation: pulse-ring 1.5s infinite; } |
|
|
</style> |
|
|
</head> |
|
|
<body class="bg-slate-50 min-h-screen"> |
|
|
|
|
|
|
|
|
<header class="asr-gradient text-white sticky top-0 z-40 shadow-xl"> |
|
|
<div class="container mx-auto px-6 py-4"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div class="flex items-center space-x-4"> |
|
|
<div class="w-12 h-12 bg-white rounded-xl flex items-center justify-center"> |
|
|
<span class="text-slate-900 font-black text-xl">ASR</span> |
|
|
</div> |
|
|
<div> |
|
|
<h1 class="text-2xl font-bold tracking-tight">Audio Intelligence Platform</h1> |
|
|
<p class="text-slate-400 text-sm">Enterprise Speech Analytics & Transcription</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex items-center space-x-6"> |
|
|
<div class="text-right"> |
|
|
<div class="text-xs text-slate-400 uppercase tracking-wider">System Status</div> |
|
|
<div class="flex items-center mt-1"> |
|
|
<span class="relative flex h-3 w-3 mr-2"> |
|
|
<span class="live-indicator absolute inline-flex h-full w-full rounded-full bg-emerald-400"></span> |
|
|
<span class="relative inline-flex rounded-full h-3 w-3 bg-emerald-500"></span> |
|
|
</span> |
|
|
<span class="font-medium" id="serverStatus">Operational</span> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</header> |
|
|
|
|
|
<main class="container mx-auto px-6 py-8"> |
|
|
|
|
|
|
|
|
<section class="glass-card rounded-2xl shadow-lg p-8 mb-8 border border-slate-200"> |
|
|
<div class="flex items-center justify-between mb-6"> |
|
|
<div> |
|
|
<h2 class="text-xl font-bold text-slate-800">Audio Upload</h2> |
|
|
<p class="text-slate-500 text-sm mt-1">Upload audio files for analysis. Stereo files will be automatically separated by channel.</p> |
|
|
</div> |
|
|
<div class="flex items-center space-x-2 text-sm"> |
|
|
<span class="px-3 py-1 bg-blue-100 text-blue-700 rounded-full font-medium">Stereo: Split Channels</span> |
|
|
<span class="px-3 py-1 bg-slate-100 text-slate-700 rounded-full font-medium">Mono: Single Speaker</span> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="grid grid-cols-1 md:grid-cols-2 gap-6"> |
|
|
|
|
|
<div class="upload-zone rounded-xl p-8 text-center cursor-pointer" id="uploadZone"> |
|
|
<input type="file" id="fileInput" class="hidden" accept=".wav,.mp3,.m4a,.flac,.ogg,.opus"> |
|
|
<div class="w-16 h-16 mx-auto mb-4 bg-slate-100 rounded-full flex items-center justify-center"> |
|
|
<i class="fas fa-cloud-arrow-up text-2xl text-slate-400"></i> |
|
|
</div> |
|
|
<p class="text-lg text-slate-700 font-medium">Drop audio file or <span class="text-blue-600 hover:underline">browse</span></p> |
|
|
<p class="text-sm text-slate-400 mt-2">WAV, MP3, M4A, FLAC, OGG, OPUS</p> |
|
|
</div> |
|
|
|
|
|
|
|
|
<div class="rounded-xl p-8 text-center border-2 border-dashed border-slate-200 hover:border-rose-400 transition-all" id="recordZone"> |
|
|
<div class="w-16 h-16 mx-auto mb-4 rounded-full flex items-center justify-center transition-all duration-300" id="micButton"> |
|
|
<button onclick="toggleRecording()" class="w-16 h-16 bg-slate-100 hover:bg-rose-100 rounded-full flex items-center justify-center transition-all duration-300" id="recordBtn"> |
|
|
<i class="fas fa-microphone text-2xl text-slate-400" id="micIcon"></i> |
|
|
</button> |
|
|
</div> |
|
|
<p class="text-lg text-slate-700 font-medium" id="recordText">Click to record</p> |
|
|
<p class="text-sm text-slate-400 mt-2" id="recordTimer">Max 5 minutes</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div id="uploadProgress" class="hidden mt-6"> |
|
|
<div class="bg-slate-50 rounded-xl p-6 border border-slate-200"> |
|
|
<div class="flex items-center justify-between mb-4"> |
|
|
<div class="flex items-center"> |
|
|
<div class="w-10 h-10 bg-blue-100 rounded-lg flex items-center justify-center mr-4"> |
|
|
<i class="fas fa-spinner fa-spin text-blue-600"></i> |
|
|
</div> |
|
|
<div> |
|
|
<p class="font-semibold text-slate-800" id="uploadStatus">Processing...</p> |
|
|
<p class="text-sm text-slate-500" id="stageText">Initializing...</p> |
|
|
</div> |
|
|
</div> |
|
|
<span id="progressPercent" class="text-2xl font-bold text-blue-600">0%</span> |
|
|
</div> |
|
|
<div class="w-full bg-slate-200 rounded-full h-2"> |
|
|
<div id="progressBar" class="asr-accent-gradient h-2 rounded-full transition-all duration-500" style="width: 0%"></div> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
|
|
|
<section class="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-6 gap-4 mb-8"> |
|
|
<div class="glass-card rounded-xl p-5 border border-slate-200 metric-card transition-all duration-300"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div> |
|
|
<p class="text-xs text-slate-500 uppercase tracking-wider font-medium">Total Calls</p> |
|
|
<p class="text-3xl font-bold text-slate-800 mt-1" id="totalCalls">0</p> |
|
|
</div> |
|
|
<div class="w-12 h-12 bg-blue-50 rounded-xl flex items-center justify-center"> |
|
|
<i class="fas fa-phone-volume text-blue-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-card rounded-xl p-5 border border-slate-200 metric-card transition-all duration-300"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div> |
|
|
<p class="text-xs text-slate-500 uppercase tracking-wider font-medium">Stereo</p> |
|
|
<p class="text-3xl font-bold text-purple-600 mt-1" id="stereoCalls">0</p> |
|
|
</div> |
|
|
<div class="w-12 h-12 bg-purple-50 rounded-xl flex items-center justify-center"> |
|
|
<i class="fas fa-code-branch text-purple-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-card rounded-xl p-5 border border-slate-200 metric-card transition-all duration-300"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div> |
|
|
<p class="text-xs text-slate-500 uppercase tracking-wider font-medium">Quality Score</p> |
|
|
<p class="text-3xl font-bold text-emerald-600 mt-1" id="avgScore">0</p> |
|
|
</div> |
|
|
<div class="w-12 h-12 bg-emerald-50 rounded-xl flex items-center justify-center"> |
|
|
<i class="fas fa-chart-line text-emerald-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-card rounded-xl p-5 border border-slate-200 metric-card transition-all duration-300"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div> |
|
|
<p class="text-xs text-slate-500 uppercase tracking-wider font-medium">Clarity</p> |
|
|
<p class="text-3xl font-bold text-cyan-600 mt-1" id="avgClarity">0</p> |
|
|
</div> |
|
|
<div class="w-12 h-12 bg-cyan-50 rounded-xl flex items-center justify-center"> |
|
|
<i class="fas fa-microphone text-cyan-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-card rounded-xl p-5 border border-slate-200 metric-card transition-all duration-300"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div> |
|
|
<p class="text-xs text-slate-500 uppercase tracking-wider font-medium">Confidence</p> |
|
|
<p class="text-3xl font-bold text-amber-600 mt-1" id="avgConfidence">0</p> |
|
|
</div> |
|
|
<div class="w-12 h-12 bg-amber-50 rounded-xl flex items-center justify-center"> |
|
|
<i class="fas fa-shield-check text-amber-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<div class="glass-card rounded-xl p-5 border border-slate-200 metric-card transition-all duration-300"> |
|
|
<div class="flex items-center justify-between"> |
|
|
<div> |
|
|
<p class="text-xs text-slate-500 uppercase tracking-wider font-medium">Segments</p> |
|
|
<p class="text-3xl font-bold text-rose-600 mt-1" id="totalSegments">0</p> |
|
|
</div> |
|
|
<div class="w-12 h-12 bg-rose-50 rounded-xl flex items-center justify-center"> |
|
|
<i class="fas fa-wave-square text-rose-600 text-lg"></i> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
</section> |
|
|
|
|
|
|
|
|
<section class="glass-card rounded-2xl shadow-lg border border-slate-200 overflow-hidden"> |
|
|
<div class="p-6 border-b border-slate-200 flex items-center justify-between"> |
|
|
<div> |
|
|
<h2 class="text-xl font-bold text-slate-800">Analyzed Recordings</h2> |
|
|
<p class="text-sm text-slate-500 mt-1">Click on any recording to view detailed analysis</p> |
|
|
</div> |
|
|
<button onclick="loadCalls()" class="flex items-center px-4 py-2 bg-slate-100 hover:bg-slate-200 text-slate-700 rounded-lg transition-colors font-medium"> |
|
|
<i class="fas fa-arrows-rotate mr-2"></i>Refresh |
|
|
</button> |
|
|
</div> |
|
|
<div id="callsList" class="divide-y divide-slate-100"></div> |
|
|
</section> |
|
|
</main> |
|
|
|
|
|
|
|
|
<div id="analysisModal" class="hidden fixed inset-0 bg-slate-900/60 backdrop-blur-sm z-50 flex items-center justify-center p-4"> |
|
|
<div class="bg-white rounded-2xl shadow-2xl max-w-7xl w-full max-h-[95vh] overflow-hidden flex flex-col"> |
|
|
<div class="asr-gradient text-white p-6 flex justify-between items-center shrink-0"> |
|
|
<div class="flex items-center space-x-4"> |
|
|
<div class="w-10 h-10 bg-white/20 rounded-lg flex items-center justify-center"> |
|
|
<i class="fas fa-chart-bar"></i> |
|
|
</div> |
|
|
<div> |
|
|
<h3 class="text-xl font-bold" id="modalTitle">Analysis Report</h3> |
|
|
<p class="text-slate-300 text-sm">Comprehensive audio analysis</p> |
|
|
</div> |
|
|
</div> |
|
|
<button onclick="closeModal()" class="w-10 h-10 bg-white/10 hover:bg-white/20 rounded-lg flex items-center justify-center transition-colors"> |
|
|
<i class="fas fa-xmark text-xl"></i> |
|
|
</button> |
|
|
</div> |
|
|
<div class="p-6 overflow-y-auto flex-1" id="modalContent"></div> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<script> |
|
|
const API_BASE = window.location.origin; |
|
|
let currentJobId = null; |
|
|
let pollInterval = null; |
|
|
|
|
|
|
|
|
let mediaRecorder = null; |
|
|
let audioChunks = []; |
|
|
let isRecording = false; |
|
|
let recordingStartTime = null; |
|
|
let timerInterval = null; |
|
|
|
|
|
document.addEventListener('DOMContentLoaded', () => { |
|
|
checkServerHealth(); |
|
|
loadStatistics(); |
|
|
loadCalls(); |
|
|
|
|
|
const uploadZone = document.getElementById('uploadZone'); |
|
|
const fileInput = document.getElementById('fileInput'); |
|
|
|
|
|
uploadZone.addEventListener('click', () => fileInput.click()); |
|
|
uploadZone.addEventListener('dragover', e => { e.preventDefault(); uploadZone.classList.add('dragover'); }); |
|
|
uploadZone.addEventListener('dragleave', () => uploadZone.classList.remove('dragover')); |
|
|
uploadZone.addEventListener('drop', e => { |
|
|
e.preventDefault(); |
|
|
uploadZone.classList.remove('dragover'); |
|
|
if (e.dataTransfer.files.length > 0) handleFileUpload(e.dataTransfer.files[0]); |
|
|
}); |
|
|
fileInput.addEventListener('change', e => { |
|
|
if (e.target.files.length > 0) handleFileUpload(e.target.files[0]); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
async function toggleRecording() { |
|
|
if (isRecording) { |
|
|
stopRecording(); |
|
|
} else { |
|
|
await startRecording(); |
|
|
} |
|
|
} |
|
|
|
|
|
async function startRecording() { |
|
|
try { |
|
|
const stream = await navigator.mediaDevices.getUserMedia({ |
|
|
audio: { |
|
|
channelCount: 1, |
|
|
echoCancellation: true, |
|
|
noiseSuppression: true |
|
|
} |
|
|
}); |
|
|
mediaRecorder = new MediaRecorder(stream, { mimeType: 'audio/webm' }); |
|
|
audioChunks = []; |
|
|
|
|
|
mediaRecorder.ondataavailable = (e) => { |
|
|
if (e.data.size > 0) audioChunks.push(e.data); |
|
|
}; |
|
|
|
|
|
mediaRecorder.onstop = () => { |
|
|
const audioBlob = new Blob(audioChunks, { type: 'audio/webm' }); |
|
|
const file = new File([audioBlob], `recording_${Date.now()}.webm`, { type: 'audio/webm' }); |
|
|
handleFileUpload(file); |
|
|
stream.getTracks().forEach(track => track.stop()); |
|
|
}; |
|
|
|
|
|
mediaRecorder.start(1000); |
|
|
isRecording = true; |
|
|
recordingStartTime = Date.now(); |
|
|
|
|
|
|
|
|
document.getElementById('recordBtn').classList.remove('bg-slate-100', 'hover:bg-rose-100'); |
|
|
document.getElementById('recordBtn').classList.add('bg-rose-500', 'animate-pulse'); |
|
|
document.getElementById('micIcon').classList.remove('text-slate-400'); |
|
|
document.getElementById('micIcon').classList.add('text-white'); |
|
|
document.getElementById('micIcon').classList.replace('fa-microphone', 'fa-stop'); |
|
|
document.getElementById('recordText').textContent = 'Recording... Click to stop'; |
|
|
document.getElementById('recordZone').classList.add('border-rose-400', 'bg-rose-50'); |
|
|
|
|
|
|
|
|
timerInterval = setInterval(updateTimer, 1000); |
|
|
|
|
|
} catch (err) { |
|
|
console.error('Microphone access denied:', err); |
|
|
alert('Mikrofona icazə verilmədi. Brauzerin icazələrini yoxlayın.'); |
|
|
} |
|
|
} |
|
|
|
|
|
function stopRecording() { |
|
|
if (mediaRecorder && isRecording) { |
|
|
mediaRecorder.stop(); |
|
|
isRecording = false; |
|
|
clearInterval(timerInterval); |
|
|
|
|
|
|
|
|
document.getElementById('recordBtn').classList.add('bg-slate-100', 'hover:bg-rose-100'); |
|
|
document.getElementById('recordBtn').classList.remove('bg-rose-500', 'animate-pulse'); |
|
|
document.getElementById('micIcon').classList.add('text-slate-400'); |
|
|
document.getElementById('micIcon').classList.remove('text-white'); |
|
|
document.getElementById('micIcon').classList.replace('fa-stop', 'fa-microphone'); |
|
|
document.getElementById('recordText').textContent = 'Click to record'; |
|
|
document.getElementById('recordTimer').textContent = 'Max 5 minutes'; |
|
|
document.getElementById('recordZone').classList.remove('border-rose-400', 'bg-rose-50'); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateTimer() { |
|
|
const elapsed = Math.floor((Date.now() - recordingStartTime) / 1000); |
|
|
const minutes = Math.floor(elapsed / 60); |
|
|
const seconds = elapsed % 60; |
|
|
document.getElementById('recordTimer').textContent = `${minutes}:${seconds.toString().padStart(2, '0')}`; |
|
|
|
|
|
|
|
|
if (elapsed >= 300) { |
|
|
stopRecording(); |
|
|
} |
|
|
} |
|
|
|
|
|
async function checkServerHealth() { |
|
|
try { |
|
|
const res = await fetch(`${API_BASE}/health`); |
|
|
const data = await res.json(); |
|
|
document.getElementById('serverStatus').textContent = data.status === 'healthy' ? 'Operational' : 'Offline'; |
|
|
} catch { document.getElementById('serverStatus').textContent = 'Offline'; } |
|
|
} |
|
|
|
|
|
async function loadStatistics() { |
|
|
try { |
|
|
const res = await fetch(`${API_BASE}/api/statistics`); |
|
|
const s = await res.json(); |
|
|
document.getElementById('totalCalls').textContent = s.total_calls || 0; |
|
|
document.getElementById('stereoCalls').textContent = s.stereo_calls || 0; |
|
|
document.getElementById('avgScore').textContent = (s.avg_quality_score || 0).toFixed(1); |
|
|
document.getElementById('avgClarity').textContent = (s.avg_clarity || 0).toFixed(1); |
|
|
document.getElementById('avgConfidence').textContent = (s.avg_confidence || 0).toFixed(1); |
|
|
document.getElementById('totalSegments').textContent = s.total_segments || 0; |
|
|
} catch (e) { console.error('Stats error:', e); } |
|
|
} |
|
|
|
|
|
async function loadCalls() { |
|
|
try { |
|
|
const res = await fetch(`${API_BASE}/api/calls`); |
|
|
const calls = await res.json(); |
|
|
const list = document.getElementById('callsList'); |
|
|
|
|
|
if (calls.length === 0) { |
|
|
list.innerHTML = `<div class="p-12 text-center"><div class="w-16 h-16 bg-slate-100 rounded-full flex items-center justify-center mx-auto mb-4"><i class="fas fa-folder-open text-2xl text-slate-400"></i></div><p class="text-slate-600 font-medium">No recordings yet</p><p class="text-slate-400 text-sm mt-1">Upload an audio file to get started</p></div>`; |
|
|
return; |
|
|
} |
|
|
|
|
|
list.innerHTML = calls.map(call => ` |
|
|
<div onclick="viewAnalysis('${call}')" class="p-5 flex items-center justify-between hover:bg-slate-50 cursor-pointer transition-colors"> |
|
|
<div class="flex items-center space-x-4"> |
|
|
<div class="w-12 h-12 bg-gradient-to-br from-blue-500 to-purple-600 rounded-xl flex items-center justify-center text-white"><i class="fas fa-waveform-lines"></i></div> |
|
|
<div> |
|
|
<p class="font-semibold text-slate-800">${call}</p> |
|
|
<p class="text-sm text-slate-500">Click to view analysis</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="flex items-center space-x-3"> |
|
|
<span class="px-3 py-1 bg-emerald-100 text-emerald-700 rounded-full text-sm font-medium">Analyzed</span> |
|
|
<i class="fas fa-chevron-right text-slate-400"></i> |
|
|
</div> |
|
|
</div> |
|
|
`).join(''); |
|
|
} catch (e) { console.error('Calls error:', e); } |
|
|
} |
|
|
|
|
|
async function handleFileUpload(file) { |
|
|
const formData = new FormData(); |
|
|
formData.append('file', file); |
|
|
|
|
|
document.getElementById('uploadProgress').classList.remove('hidden'); |
|
|
updateProgress('Uploading...', 'Transferring file to server', 10); |
|
|
|
|
|
try { |
|
|
const res = await fetch(`${API_BASE}/api/upload`, { method: 'POST', body: formData }); |
|
|
const result = await res.json(); |
|
|
if (!res.ok) throw new Error(result.error); |
|
|
currentJobId = result.job_id; |
|
|
updateProgress('Processing...', 'Analysis started', 25); |
|
|
pollJobStatus(); |
|
|
} catch (e) { |
|
|
updateProgress('Error', e.message, 0); |
|
|
setTimeout(() => document.getElementById('uploadProgress').classList.add('hidden'), 3000); |
|
|
} |
|
|
} |
|
|
|
|
|
function updateProgress(status, stage, percent) { |
|
|
document.getElementById('uploadStatus').textContent = status; |
|
|
document.getElementById('stageText').textContent = stage; |
|
|
document.getElementById('progressBar').style.width = percent + '%'; |
|
|
document.getElementById('progressPercent').textContent = percent + '%'; |
|
|
} |
|
|
|
|
|
function pollJobStatus() { |
|
|
if (pollInterval) clearInterval(pollInterval); |
|
|
pollInterval = setInterval(async () => { |
|
|
try { |
|
|
const res = await fetch(`${API_BASE}/api/jobs/${currentJobId}`); |
|
|
const job = await res.json(); |
|
|
|
|
|
const stages = { |
|
|
'pending': { text: 'Queued...', progress: 20 }, |
|
|
'initializing': { text: 'Loading models...', progress: 30 }, |
|
|
'diarization': { text: 'Separating speakers...', progress: 45 }, |
|
|
'transcription': { text: 'Transcribing speech...', progress: 65 }, |
|
|
'audio_analysis': { text: 'Analyzing audio features...', progress: 85 }, |
|
|
'done': { text: 'Complete!', progress: 100 } |
|
|
}; |
|
|
|
|
|
if (job.stage && stages[job.stage]) { |
|
|
let stageText = stages[job.stage].text; |
|
|
if (job.is_stereo !== null) stageText += job.is_stereo ? ' (Stereo)' : ' (Mono)'; |
|
|
updateProgress('Processing...', stageText, stages[job.stage].progress); |
|
|
} |
|
|
|
|
|
if (job.status === 'completed') { |
|
|
clearInterval(pollInterval); |
|
|
updateProgress('Success!', 'Analysis complete', 100); |
|
|
setTimeout(() => { |
|
|
document.getElementById('uploadProgress').classList.add('hidden'); |
|
|
loadStatistics(); |
|
|
loadCalls(); |
|
|
}, 1500); |
|
|
} else if (job.status === 'failed') { |
|
|
clearInterval(pollInterval); |
|
|
updateProgress('Failed', job.error || 'Unknown error', 0); |
|
|
setTimeout(() => document.getElementById('uploadProgress').classList.add('hidden'), 3000); |
|
|
} |
|
|
} catch (e) { clearInterval(pollInterval); } |
|
|
}, 1500); |
|
|
} |
|
|
|
|
|
async function viewAnalysis(callName) { |
|
|
try { |
|
|
const res = await fetch(`${API_BASE}/api/analysis/${callName}`); |
|
|
const data = await res.json(); |
|
|
const a = data.analysis; |
|
|
const t = data.transcription; |
|
|
|
|
|
document.getElementById('modalTitle').textContent = callName; |
|
|
|
|
|
const isStereo = a.audio_type === 'stereo'; |
|
|
const profiles = a.speaker_profiles || {}; |
|
|
const dynamics = a.dynamics || {}; |
|
|
|
|
|
let profilesHTML = ''; |
|
|
for (const [spk, p] of Object.entries(profiles)) { |
|
|
profilesHTML += ` |
|
|
<div class="bg-slate-50 rounded-xl p-5 border border-slate-200"> |
|
|
<div class="flex items-center justify-between mb-4"> |
|
|
<div class="flex items-center space-x-3"> |
|
|
<div class="w-10 h-10 ${spk === 'CUSTOMER' ? 'bg-blue-100' : spk === 'AGENT' ? 'bg-emerald-100' : 'bg-purple-100'} rounded-full flex items-center justify-center"> |
|
|
<i class="fas fa-user ${spk === 'CUSTOMER' ? 'text-blue-600' : spk === 'AGENT' ? 'text-emerald-600' : 'text-purple-600'}"></i> |
|
|
</div> |
|
|
<div> |
|
|
<p class="font-bold text-slate-800">${spk}</p> |
|
|
<p class="text-sm text-slate-500">${p.communication_style} style</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="text-right"> |
|
|
<p class="text-2xl font-bold text-slate-800">${p.overall_score}</p> |
|
|
<p class="text-xs text-slate-500">Quality Score</p> |
|
|
</div> |
|
|
</div> |
|
|
<div class="grid grid-cols-4 gap-3 mb-4"> |
|
|
<div class="text-center p-2 bg-white rounded-lg"><p class="text-lg font-bold text-slate-800">${p.avg_pitch.toFixed(0)}</p><p class="text-xs text-slate-500">Pitch (Hz)</p></div> |
|
|
<div class="text-center p-2 bg-white rounded-lg"><p class="text-lg font-bold text-slate-800">${p.avg_energy.toFixed(1)}</p><p class="text-xs text-slate-500">Energy (dB)</p></div> |
|
|
<div class="text-center p-2 bg-white rounded-lg"><p class="text-lg font-bold text-slate-800">${p.avg_speaking_rate.toFixed(1)}</p><p class="text-xs text-slate-500">Rate (/s)</p></div> |
|
|
<div class="text-center p-2 bg-white rounded-lg"><p class="text-lg font-bold text-slate-800">${p.avg_clarity.toFixed(0)}</p><p class="text-xs text-slate-500">Clarity</p></div> |
|
|
</div> |
|
|
<div class="flex flex-wrap gap-2"> |
|
|
${p.strengths.map(s => `<span class="px-2 py-1 bg-emerald-100 text-emerald-700 text-xs rounded-full">${s}</span>`).join('')} |
|
|
${p.improvements.map(i => `<span class="px-2 py-1 bg-amber-100 text-amber-700 text-xs rounded-full">${i}</span>`).join('')} |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
} |
|
|
|
|
|
let transcriptHTML = ''; |
|
|
let totalInferenceTime = 0; |
|
|
if (t && t.transcriptions) { |
|
|
t.transcriptions.forEach(seg => totalInferenceTime += (seg.inference_time || 0)); |
|
|
transcriptHTML = t.transcriptions.map(seg => ` |
|
|
<div class="flex ${seg.speaker === 'CUSTOMER' ? 'justify-start' : seg.speaker === 'AGENT' ? 'justify-end' : 'justify-center'}"> |
|
|
<div class="max-w-[70%] ${seg.speaker === 'CUSTOMER' ? 'bg-blue-50 border-blue-200' : seg.speaker === 'AGENT' ? 'bg-emerald-50 border-emerald-200' : 'bg-slate-50 border-slate-200'} border rounded-xl p-3"> |
|
|
<div class="flex items-center justify-between mb-1"> |
|
|
<div class="flex items-center space-x-2"> |
|
|
<span class="font-semibold text-sm ${seg.speaker === 'CUSTOMER' ? 'text-blue-700' : seg.speaker === 'AGENT' ? 'text-emerald-700' : 'text-slate-700'}">${seg.speaker}</span> |
|
|
<span class="text-xs text-slate-400">${seg.start_time}</span> |
|
|
</div> |
|
|
<span class="text-xs text-orange-500 font-medium"><i class="fas fa-clock mr-1"></i>${seg.inference_time}s</span> |
|
|
</div> |
|
|
<p class="text-slate-800">${seg.text}</p> |
|
|
</div> |
|
|
</div> |
|
|
`).join(''); |
|
|
} |
|
|
|
|
|
document.getElementById('modalContent').innerHTML = ` |
|
|
<div class="space-y-6"> |
|
|
<!-- Overview --> |
|
|
<div class="grid grid-cols-5 gap-4"> |
|
|
<div class="bg-gradient-to-br from-slate-800 to-slate-900 text-white rounded-xl p-5 text-center"> |
|
|
<p class="text-3xl font-bold">${a.overall_quality_score}</p> |
|
|
<p class="text-slate-300 text-sm mt-1">Quality Score</p> |
|
|
</div> |
|
|
<div class="bg-slate-50 rounded-xl p-5 text-center border border-slate-200"> |
|
|
<p class="text-3xl font-bold text-slate-800">${a.audio_duration.toFixed(1)}s</p> |
|
|
<p class="text-slate-500 text-sm mt-1">Duration</p> |
|
|
</div> |
|
|
<div class="bg-slate-50 rounded-xl p-5 text-center border border-slate-200"> |
|
|
<p class="text-3xl font-bold ${isStereo ? 'text-purple-600' : 'text-blue-600'}">${isStereo ? 'STEREO' : 'MONO'}</p> |
|
|
<p class="text-slate-500 text-sm mt-1">Audio Type</p> |
|
|
</div> |
|
|
<div class="bg-slate-50 rounded-xl p-5 text-center border border-slate-200"> |
|
|
<p class="text-3xl font-bold text-slate-800">${a.segments.length}</p> |
|
|
<p class="text-slate-500 text-sm mt-1">Segments</p> |
|
|
</div> |
|
|
<div class="bg-slate-50 rounded-xl p-5 text-center border border-slate-200"> |
|
|
<p class="text-3xl font-bold text-emerald-600">${dynamics.engagement_score?.toFixed(0) || 0}</p> |
|
|
<p class="text-slate-500 text-sm mt-1">Engagement</p> |
|
|
</div> |
|
|
</div> |
|
|
|
|
|
<!-- Speaker Profiles --> |
|
|
<div> |
|
|
<h4 class="font-bold text-slate-800 mb-4 flex items-center"><i class="fas fa-users text-blue-600 mr-2"></i>Speaker Profiles</h4> |
|
|
<div class="grid ${isStereo ? 'grid-cols-2' : 'grid-cols-1 max-w-xl'} gap-4">${profilesHTML}</div> |
|
|
</div> |
|
|
|
|
|
<!-- Transcription --> |
|
|
${t ? ` |
|
|
<div> |
|
|
<div class="flex items-center justify-between mb-4"> |
|
|
<h4 class="font-bold text-slate-800 flex items-center"><i class="fas fa-closed-captioning text-blue-600 mr-2"></i>Transcription</h4> |
|
|
<span class="px-3 py-1 bg-orange-100 text-orange-700 rounded-full text-sm font-medium"><i class="fas fa-bolt mr-1"></i>Total: ${totalInferenceTime.toFixed(2)}s</span> |
|
|
</div> |
|
|
<div class="bg-slate-50 rounded-xl p-4 border border-slate-200 max-h-80 overflow-y-auto space-y-3">${transcriptHTML}</div> |
|
|
</div> |
|
|
` : ''} |
|
|
|
|
|
<!-- Segment Analysis --> |
|
|
<div> |
|
|
<h4 class="font-bold text-slate-800 mb-4 flex items-center"><i class="fas fa-wave-square text-blue-600 mr-2"></i>Segment Analysis</h4> |
|
|
<div class="bg-slate-50 rounded-xl border border-slate-200 overflow-hidden max-h-96 overflow-y-auto"> |
|
|
<table class="w-full text-sm"> |
|
|
<thead class="bg-slate-100 sticky top-0"> |
|
|
<tr> |
|
|
<th class="px-4 py-3 text-left font-semibold text-slate-600">#</th> |
|
|
<th class="px-4 py-3 text-left font-semibold text-slate-600">Speaker</th> |
|
|
<th class="px-4 py-3 text-left font-semibold text-slate-600">Time</th> |
|
|
<th class="px-4 py-3 text-center font-semibold text-slate-600">Pitch</th> |
|
|
<th class="px-4 py-3 text-center font-semibold text-slate-600">Energy</th> |
|
|
<th class="px-4 py-3 text-center font-semibold text-slate-600">Rate</th> |
|
|
<th class="px-4 py-3 text-center font-semibold text-slate-600">Clarity</th> |
|
|
<th class="px-4 py-3 text-center font-semibold text-slate-600">Emotion</th> |
|
|
<th class="px-4 py-3 text-center font-semibold text-slate-600">Score</th> |
|
|
</tr> |
|
|
</thead> |
|
|
<tbody class="divide-y divide-slate-100"> |
|
|
${a.segments.map((s, i) => ` |
|
|
<tr class="segment-row"> |
|
|
<td class="px-4 py-3 font-medium text-slate-800">${i+1}</td> |
|
|
<td class="px-4 py-3"><span class="px-2 py-1 ${s.speaker === 'CUSTOMER' ? 'bg-blue-100 text-blue-700' : s.speaker === 'AGENT' ? 'bg-emerald-100 text-emerald-700' : 'bg-purple-100 text-purple-700'} rounded-full text-xs font-medium">${s.speaker}</span></td> |
|
|
<td class="px-4 py-3 text-slate-600">${s.start_time}</td> |
|
|
<td class="px-4 py-3 text-center font-medium">${s.pitch.mean_f0.toFixed(0)} Hz</td> |
|
|
<td class="px-4 py-3 text-center font-medium">${s.energy.mean_rms.toFixed(1)} dB</td> |
|
|
<td class="px-4 py-3 text-center font-medium">${s.rhythm.speaking_rate.toFixed(1)}</td> |
|
|
<td class="px-4 py-3 text-center"><span class="px-2 py-1 ${s.voice_quality.clarity_score > 70 ? 'bg-emerald-100 text-emerald-700' : s.voice_quality.clarity_score > 50 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'} rounded text-xs font-medium">${s.voice_quality.clarity_score.toFixed(0)}</span></td> |
|
|
<td class="px-4 py-3 text-center"><span class="px-2 py-1 bg-slate-100 text-slate-700 rounded text-xs">${s.emotion.primary_emotion}</span></td> |
|
|
<td class="px-4 py-3 text-center font-bold text-slate-800">${s.overall_quality_score.toFixed(0)}</td> |
|
|
</tr> |
|
|
`).join('')} |
|
|
</tbody> |
|
|
</table> |
|
|
</div> |
|
|
</div> |
|
|
</div> |
|
|
`; |
|
|
|
|
|
document.getElementById('analysisModal').classList.remove('hidden'); |
|
|
} catch (e) { console.error('Analysis error:', e); alert('Failed to load analysis'); } |
|
|
} |
|
|
|
|
|
function closeModal() { document.getElementById('analysisModal').classList.add('hidden'); } |
|
|
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); }); |
|
|
</script> |
|
|
</body> |
|
|
</html> |
|
|
|