Voice-guard / index.html
varunkul's picture
Upload 8 files
6ecef58 verified
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width,initial-scale=1" />
<title>Voice Guard — AI Voice Detector</title>
<!-- Tailwind (CDN) -->
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: { sans: ['Inter','ui-sans-serif','system-ui'] },
colors: {
brand: { 400:'#ff8e34', 500:'#ff6a00' }
},
boxShadow: { glass: '0 10px 30px rgba(0,0,0,.35), inset 0 1px 0 rgba(255,255,255,.05)' }
}
}
}
</script>
<!-- Optional config file that sets window.BACKEND_URL -->
<script src="config.js"></script>
<!-- Inter font -->
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;600;700;800&display=swap" rel="stylesheet">
<style>
.glass{
background: rgba(28,28,33,.55);
border: 1px solid rgba(255,255,255,.06);
box-shadow: var(--glass, 0 10px 30px rgba(0,0,0,.35)), inset 0 1px 0 rgba(255,255,255,.05);
backdrop-filter: blur(10px);
}
.donut{
--val:.34; --col:#ff6a00;
background: conic-gradient(var(--col) calc(var(--val)*360deg), #2c2c2c 0);
mask: radial-gradient(farthest-side, #0000 62%, #000 63%);
-webkit-mask: radial-gradient(farthest-side, #0000 62%, #000 63%);
transition: background .35s ease;
}
::-webkit-scrollbar { width: 10px; height: 10px; }
::-webkit-scrollbar-thumb { background: #23242a; border-radius: 999px; }
</style>
</head>
<body class="bg-[#0C0D10] text-white font-sans">
<!-- Soft gradient background -->
<div class="fixed inset-0 -z-10">
<div class="absolute -top-24 -right-24 w-[600px] h-[600px] rounded-full blur-3xl opacity-30"
style="background: radial-gradient(closest-side,#ff6a00,transparent 70%);"></div>
<div class="absolute -bottom-24 -left-20 w-[500px] h-[500px] rounded-full blur-3xl opacity-20"
style="background: radial-gradient(closest-side,#5eead4,transparent 70%);"></div>
</div>
<!-- Header -->
<header class="mx-auto max-w-7xl px-6 py-4">
<div class="flex items-center justify-between rounded-2xl glass px-4 py-3">
<div class="flex items-center gap-3">
<div class="h-9 w-9 rounded-xl bg-brand-500/10 ring-1 ring-brand-500/40 grid place-content-center">
<!-- Shield waveform icon -->
<svg width="20" height="20" viewBox="0 0 24 24" fill="none">
<path d="M12 2l7 3v6c0 5.25-3.5 9.75-7 11-3.5-1.25-7-5.75-7-11V5l7-3Z" stroke="#ff6a00" stroke-width="1.5"/>
<path d="M7 12h2l1-4 2 8 1-6 1 3h3" stroke="#fff" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</div>
<div>
<h1 class="text-lg font-semibold">Voice Guard</h1>
<p class="text-xs text-white/50 -mt-0.5">Human vs AI Speech</p>
</div>
</div>
<button id="analyzeBtn"
class="rounded-xl px-4 py-2.5 text-sm font-semibold bg-brand-500 hover:bg-brand-400 text-white shadow-lg">
Analyze
</button>
</div>
</header>
<!-- Main -->
<main class="mx-auto max-w-7xl px-6 pb-24">
<div class="grid grid-cols-12 gap-6">
<!-- LEFT: Inputs + status -->
<section class="col-span-4 space-y-6">
<div class="glass rounded-2xl p-5">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/80">Input</h2>
<span class="text-xs px-2 py-1 rounded-full bg-white/5 border border-white/10">3–7s</span>
</div>
<div class="mt-4 grid grid-cols-2 gap-2">
<button id="tabMic" class="w-full rounded-xl border border-white/10 bg-white/5 px-3 py-2 text-sm font-medium">
Microphone
</button>
<button id="tabUpload" class="w-full rounded-xl border border-white/10 bg-transparent px-3 py-2 text-sm font-medium hover:bg-white/5">
Upload
</button>
</div>
<!-- Mic panel -->
<div id="micPanel" class="mt-4 space-y-4">
<div class="flex items-center gap-3">
<button id="recBtn" class="rounded-lg bg-white/10 hover:bg-white/20 px-3 py-2 text-sm border border-white/10">● Record</button>
<span id="recStatus" class="text-xs text-white/60">Idle</span>
</div>
<div class="rounded-xl border border-white/10 bg-black/30 h-24 overflow-hidden">
<canvas id="meter" class="w-full h-full"></canvas>
</div>
</div>
<!-- Upload panel -->
<div id="uploadPanel" class="mt-4 hidden space-y-3">
<label class="block text-sm text-white/70">Choose audio (.wav/.mp3)</label>
<input id="fileInput" type="file" accept="audio/*"
class="w-full rounded-xl bg-black/30 border border-white/10 p-3 text-sm file:mr-4 file:rounded-lg file:border-0 file:bg-brand-500 file:px-4 file:py-2 file:text-white file:text-sm hover:file:bg-brand-400"/>
<p id="fileName" class="text-xs text-white/50"></p>
</div>
</div>
<div class="grid grid-cols-2 gap-4">
<div class="glass rounded-2xl p-4">
<p class="text-xs text-white/60">Source</p>
<p id="srcLabel" class="mt-1 text-lg font-semibold">Microphone</p>
</div>
<div class="glass rounded-2xl p-4">
<p class="text-xs text-white/60">Latency</p>
<p id="latency" class="mt-1 text-lg font-semibold"></p>
</div>
</div>
<div class="glass rounded-2xl p-5">
<div class="flex items-center justify-between">
<h3 class="text-sm font-semibold text-white/80">Recent</h3>
<button id="clearRecent" class="text-xs text-white/50 hover:text-white/80">Clear</button>
</div>
<ul id="recentList" class="mt-3 space-y-2 max-h-60 overflow-auto"></ul>
</div>
</section>
<!-- RIGHT: Heatmap + donuts + label -->
<section class="col-span-8 space-y-6">
<div class="glass rounded-2xl p-5">
<div class="flex items-center justify-between">
<h2 class="text-sm font-semibold text-white/80">Explanation Heatmap</h2>
<div class="text-xs text-white/50">Spectrogram importance</div>
</div>
<div class="mt-4 h-[340px] rounded-xl border border-white/10 overflow-hidden bg-black/30 grid place-items-center">
<img id="heatmapImg" class="w-full h-full object-contain" alt="Heatmap"/>
<span id="heatmapPlaceholder" class="text-white/50 text-sm">No analysis yet</span>
</div>
</div>
<div class="grid grid-cols-3 gap-6">
<div class="glass rounded-2xl p-5">
<div class="flex items-center justify-between">
<p class="text-sm text-white/70">Human</p>
<span class="text-xs rounded-full bg-emerald-400/15 text-emerald-300 px-2 py-0.5 border border-emerald-400/30">Class 0</span>
</div>
<div class="mt-4 flex items-center gap-6">
<div class="donut size-28 rounded-full" id="donutHuman" style="--val:.50; --col:#34d399"></div>
<div>
<p class="text-3xl font-extrabold"><span id="humanPct">50</span>%</p>
<p class="text-xs text-white/60 mt-1">Likelihood</p>
</div>
</div>
</div>
<div class="glass rounded-2xl p-5">
<div class="flex items-center justify-between">
<p class="text-sm text-white/70">AI</p>
<span class="text-xs rounded-full bg-rose-400/15 text-rose-300 px-2 py-0.5 border border-rose-400/30">Class 1</span>
</div>
<div class="mt-4 flex items-center gap-6">
<div class="donut size-28 rounded-full" id="donutAI" style="--val:.50; --col:#fb7185"></div>
<div>
<p class="text-3xl font-extrabold"><span id="aiPct">50</span>%</p>
<p class="text-xs text-white/60 mt-1">Likelihood</p>
</div>
</div>
</div>
<div class="glass rounded-2xl p-5">
<p class="text-sm text-white/70">Final Label</p>
<div class="mt-3 flex items-center gap-3">
<div id="badgeLabel"
class="px-3 py-1.5 text-sm font-semibold rounded-xl border border-emerald-400/30 bg-emerald-400/15 text-emerald-300">
HUMAN
</div>
<span id="threshold" class="text-xs text-white/50">thr 0.60</span>
</div>
<!-- NEW: why line -->
<p id="whyText" class="text-xs text-white/60 mt-2"></p>
<p class="text-xs text-white/60 mt-3 leading-relaxed">
Click Analyze to send audio to the API and render the real heatmap.
</p>
</div>
</div>
</section>
</div>
</main>
<script>
// ===== Config =====
const BACKEND_URL = window.BACKEND_URL || "http://127.0.0.1:8000/analyze";
// ===== Elements =====
const tabMic = document.getElementById('tabMic');
const tabUpload = document.getElementById('tabUpload');
const micPanel = document.getElementById('micPanel');
const uploadPanel = document.getElementById('uploadPanel');
const srcLabel = document.getElementById('srcLabel');
const recBtn = document.getElementById('recBtn');
const recStatus = document.getElementById('recStatus');
const analyzeBtn = document.getElementById('analyzeBtn');
const latency = document.getElementById('latency');
const heatmapImg = document.getElementById('heatmapImg');
const heatmapPlaceholder = document.getElementById('heatmapPlaceholder');
const donutHuman = document.getElementById('donutHuman');
const donutAI = document.getElementById('donutAI');
const humanPct = document.getElementById('humanPct');
const aiPct = document.getElementById('aiPct');
const badge = document.getElementById('badgeLabel');
const thresholdEl = document.getElementById('threshold');
const whyText = document.getElementById('whyText');
const fileInput = document.getElementById('fileInput');
const fileName = document.getElementById('fileName');
const recentList = document.getElementById('recentList');
const clearRecent = document.getElementById('clearRecent');
// ===== Tabs =====
function setTab(which){
if(which==='mic'){
tabMic.classList.add('bg-white/5');
tabUpload.classList.remove('bg-white/5');
micPanel.classList.remove('hidden');
uploadPanel.classList.add('hidden');
srcLabel.textContent = 'Microphone';
}else{
tabUpload.classList.add('bg-white/5');
tabMic.classList.remove('bg-white/5');
uploadPanel.classList.remove('hidden');
micPanel.classList.add('hidden');
srcLabel.textContent = 'Upload';
}
}
tabMic.onclick = ()=> setTab('mic');
tabUpload.onclick = ()=> setTab('upload');
setTab('mic');
// ===== Upload label =====
fileInput.onchange = ()=> fileName.textContent = fileInput.files?.[0]?.name || '';
// ===== Mic + meter =====
const meterCanvas = document.getElementById('meter');
const mctx = meterCanvas.getContext('2d');
const resizeMeter = ()=>{ meterCanvas.width = meterCanvas.clientWidth; meterCanvas.height = meterCanvas.clientHeight; };
resizeMeter(); addEventListener('resize', resizeMeter);
let mediaRecorder, chunks=[], micStream=null, audioCtx=null, analyser=null, raf=null, lastRecordedBlob=null;
function loopMeter(){
const w=meterCanvas.width, h=meterCanvas.height;
const data = new Uint8Array(analyser.frequencyBinCount);
const draw = ()=>{
analyser.getByteFrequencyData(data);
mctx.fillStyle = '#0b0b0f'; mctx.fillRect(0,0,w,h);
const bars = 48, barW = w/bars;
for (let i=0;i<bars;i++){
const v=data[i]/255, bh=v*h*0.9, x=i*barW+2, y=h-bh;
mctx.fillStyle = `rgba(255,106,0,${0.35+0.65*v})`;
mctx.fillRect(x,y,barW-4,bh);
}
raf = requestAnimationFrame(draw);
};
draw();
}
async function startRecording(){
if(micStream) return;
micStream = await navigator.mediaDevices.getUserMedia({audio:true});
mediaRecorder = new MediaRecorder(micStream, {mimeType: 'audio/webm'});
mediaRecorder.ondataavailable = e => { if(e.data.size>0) chunks.push(e.data); };
mediaRecorder.onstop = () => { lastRecordedBlob = new Blob(chunks, {type:'audio/webm'}); chunks = []; recStatus.textContent='Recorded'; };
mediaRecorder.start();
recStatus.textContent = 'Recording…';
recBtn.textContent = '■ Stop';
audioCtx = new (window.AudioContext||window.webkitAudioContext)();
const source = audioCtx.createMediaStreamSource(micStream);
analyser = audioCtx.createAnalyser(); analyser.fftSize = 1024;
source.connect(analyser); loopMeter();
}
function stopRecording(){
if(!micStream) return;
mediaRecorder?.stop();
micStream.getTracks().forEach(t => t.stop());
micStream=null;
cancelAnimationFrame(raf); audioCtx.close();
recBtn.textContent='● Record'; recStatus.textContent='Idle';
}
recBtn.onclick = ()=> micStream ? stopRecording() : startRecording();
// ===== Audio helpers: decode -> resample(16k mono) -> PCM16 -> WAV =====
async function blobToPCM(blob){
const arr = await blob.arrayBuffer();
const ctx = new (window.AudioContext||window.webkitAudioContext)();
const buf = await ctx.decodeAudioData(arr);
let pcm = buf.getChannelData(0);
if (buf.numberOfChannels>1){
const r = buf.getChannelData(1);
const n = Math.min(pcm.length, r.length);
const m = new Float32Array(n);
for (let i=0;i<n;i++) m[i] = 0.5*(pcm[i]+r[i]);
pcm = m;
}
await ctx.close();
return {pcm, sr: buf.sampleRate};
}
function resampleLinear(pcm, fromSr, toSr=16000){
if (fromSr===toSr) return pcm;
const ratio=toSr/fromSr, n=Math.round(pcm.length*ratio), out=new Float32Array(n);
for (let i=0;i<n;i++){
const x=i/ratio, i0=Math.floor(x), i1=Math.min(i0+1, pcm.length-1), t=x-i0;
out[i]=(1-t)*pcm[i0]+t*pcm[i1];
}
return out;
}
function floatTo16(pcm){
const out = new Int16Array(pcm.length);
for (let i=0;i<pcm.length;i++){ let s=Math.max(-1,Math.min(1,pcm[i])); out[i]=s<0?s*0x8000:s*0x7fff; }
return out;
}
function wavEncodePCM16(int16, sampleRate=16000, numChannels=1){
const byteRate=sampleRate*numChannels*2, blockAlign=numChannels*2;
const buffer=new ArrayBuffer(44 + int16.length*2), view=new DataView(buffer); let off=0;
const WU8=s=>{for(let i=0;i<s.length;i++) view.setUint8(off++, s.charCodeAt(i));}
const W32=v=>{view.setUint32(off,v,true); off+=4}, W16=v=>{view.setUint16(off,v,true); off+=2}
WU8('RIFF'); W32(36+int16.length*2); WU8('WAVE'); WU8('fmt '); W32(16);
W16(1); W16(numChannels); W32(sampleRate); W32(byteRate); W16(blockAlign); W16(16);
WU8('data'); W32(int16.length*2);
new Int16Array(buffer,44).set(int16);
return new Blob([buffer], {type:'audio/wav'});
}
// ===== UI helpers =====
function setBadgeFromBackend(out){
const isAI = (out.label || '').toLowerCase() === 'ai';
badge.textContent = isAI ? 'AI' : 'HUMAN';
badge.className = isAI
? "px-3 py-1.5 text-sm font-semibold rounded-xl border border-rose-400/30 bg-rose-400/15 text-rose-300"
: "px-3 py-1.5 text-sm font-semibold rounded-xl border border-emerald-400/30 bg-emerald-400/15 text-emerald-300";
}
function setWhyLine(out){
const src = out.threshold_source || '—';
const dec = out.decision || 'threshold';
const rs = (typeof out.replay_score === 'number') ? out.replay_score.toFixed(2) : '—';
const aiP = (out.ai*100).toFixed(1);
const thr = out.threshold;
const thrPct = (thr*100).toFixed(0);
const margin = (out.ai - thr).toFixed(2);
whyText.textContent = `Decision: ${dec} | AI=${aiP}% | thr(${src})=${thrPct}% | margin=${margin} | replay=${rs}`;
}
function addRecent({src,label,ph,pa}){
const li = document.createElement('li');
li.className = "flex items-center justify-between rounded-xl border border-white/10 bg-white/5 px-3 py-2";
li.innerHTML = `
<div class="flex items-center gap-3">
<span class="text-xs px-2 py-0.5 rounded-full border ${src==='Mic'?'border-indigo-400/40 bg-indigo-400/15 text-indigo-300':'border-amber-400/40 bg-amber-400/15 text-amber-300'}">${src}</span>
<span class="text-sm">${Math.round(ph*100)}% human / ${Math.round(pa*100)}% AI</span>
</div>
<span class="text-xs px-2 py-0.5 rounded-lg ${label==='AI'?'bg-rose-400/15 text-rose-300 border border-rose-400/30':'bg-emerald-400/15 text-emerald-300 border border-emerald-400/30'}">${label}</span>
`;
recentList.prepend(li);
}
clearRecent.onclick = ()=> { recentList.innerHTML=''; };
// ===== Analyze =====
analyzeBtn.onclick = async ()=>{
const t0 = performance.now();
try{
const isMic = !micPanel.classList.contains('hidden');
let blob = null;
if (isMic){
if(!lastRecordedBlob){ alert('Record 3–7 seconds first.'); return; }
blob = lastRecordedBlob;
} else {
if(!fileInput.files?.length){ alert('Choose an audio file.'); return; }
blob = fileInput.files[0];
}
// Decode -> resample -> WAV
const {pcm, sr} = await blobToPCM(blob);
const pcm16k = resampleLinear(pcm, sr, 16000);
const int16 = floatTo16(pcm16k);
const wavBlob = wavEncodePCM16(int16, 16000, 1);
const form = new FormData();
form.append('file', new File([wavBlob], 'audio.wav', {type:'audio/wav'}));
form.append('source_hint', isMic ? 'microphone' : 'upload');
const res = await fetch(BACKEND_URL, { method:'POST', body: form });
if(!res.ok) throw new Error(`API ${res.status}`);
const out = await res.json();
// Heatmap
heatmapImg.src = out.heatmap_b64;
heatmapPlaceholder.style.display = 'none';
// Donuts & numbers
const ph = out.human, pa = out.ai, thr = out.threshold;
humanPct.textContent = Math.round(ph*100);
aiPct.textContent = Math.round(pa*100);
donutHuman.style.setProperty('--val', ph.toFixed(3));
donutAI.style.setProperty('--val', pa.toFixed(3));
thresholdEl.textContent = `thr ${thr.toFixed(2)}`;
// Final label: TRUST BACKEND
setBadgeFromBackend(out);
setWhyLine(out);
latency.textContent = `${Math.round(performance.now()-t0)} ms`;
addRecent({src: isMic?'Mic':'Upload', label: out.label.toUpperCase(), ph, pa});
}catch(err){
console.error(err);
alert('Analyze failed. Check console & backend URL in config.js');
}
};
</script>
</body>
</html>