Spaces:
Running
Running
| <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> |