Deepfake Authenticator commited on
Commit
03f204c
Β·
1 Parent(s): faea1f9

fix: audio-visual mismatch detection for face-swap deepfakes

Browse files

- Swap audio model to Vansh180/deepfake-audio-wav2vec2 (ASVspoof-trained, 92.8% acc)
- Add AV_MISMATCH detection: when visual says FAKE but voice is human
this is the hallmark of face-swap deepfakes (dubbed movie audio)
- AV_MISMATCH boosts audio fake score based on visual confidence
- New result label: AV_MISMATCH (yellow warning) vs AI_VOICE (red) vs HUMAN_VOICE (green)
- Frontend: yellow warning badge + icon for AV_MISMATCH state
- Fix tooltip text visibility (white on dark, colored border)
- Fix timeline bar heights (px not %, overflow:visible for tooltips)

backend/audio_detector.py CHANGED
@@ -221,44 +221,47 @@ class AudioAnalysisAgent:
221
  # Wav2Vec2 model for AI voice detection
222
  # ─────────────────────────────────────────────
223
  class AudioDecisionAgent:
224
- MODEL_ID = "Bisher/wav2vec2_ASV_deepfake_audio_detection"
225
- CHUNK_SEC = 10 # Process in 10-second chunks (model limit)
 
226
  TARGET_SR = 16000
227
 
228
  def __init__(self):
229
  self.model = None
230
  self.processor = None
231
- self.fake_idx = 0 # label 0 = 'fake'
232
  self.available = False
233
  self._load()
234
 
235
  def _load(self):
236
  try:
237
  from transformers import (
238
- Wav2Vec2ForSequenceClassification,
239
- Wav2Vec2Processor,
240
  )
241
  logger.info(f"Loading audio model: {self.MODEL_ID}")
242
- self.processor = Wav2Vec2Processor.from_pretrained(self.MODEL_ID)
243
- self.model = Wav2Vec2ForSequenceClassification.from_pretrained(self.MODEL_ID)
244
  self.model.eval()
245
 
246
- # Confirm fake label index
247
  for idx, lbl in self.model.config.id2label.items():
248
- if lbl.lower() == "fake":
 
249
  self.fake_idx = idx
250
  break
251
 
252
  self.available = True
253
- logger.info(f"Audio model loaded β€” fake_idx={self.fake_idx}, labels={self.model.config.id2label}")
 
 
 
254
  except Exception as e:
255
  logger.warning(f"Audio model unavailable: {e}")
256
  self.available = False
257
 
258
  def predict(self, waveform: np.ndarray, sr: int) -> float:
259
- """
260
- Run Wav2Vec2 on audio in chunks, return mean fake probability.
261
- """
262
  if not self.available:
263
  return 0.5
264
 
@@ -268,7 +271,7 @@ class AudioDecisionAgent:
268
  chunks = [
269
  waveform[i : i + chunk_size]
270
  for i in range(0, len(waveform), chunk_size)
271
- if len(waveform[i : i + chunk_size]) > sr // 2 # skip < 0.5s chunks
272
  ]
273
 
274
  if not chunks:
@@ -310,6 +313,7 @@ class AudioReportAgent:
310
  model_prob: float,
311
  heuristic: dict,
312
  has_audio: bool,
 
313
  ) -> dict:
314
  if not has_audio:
315
  return {
@@ -329,21 +333,46 @@ class AudioReportAgent:
329
  else:
330
  combined = model_prob
331
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
332
  combined = float(np.clip(combined, 0.0, 1.0))
333
  is_fake = combined >= self.FAKE_THRESHOLD
334
  confidence = round(combined * 100, 1)
335
 
336
- details = self._build_details(combined, is_fake, features, model_prob, heur_prob)
 
 
 
 
 
 
337
 
338
  return {
339
- "available": True,
340
- "result": "AI_VOICE" if is_fake else "HUMAN_VOICE",
341
- "confidence": confidence,
342
  "fake_probability": round(combined, 4),
343
- "model_score": round(model_prob * 100, 1),
344
- "heuristic_score": round(heur_prob * 100, 1),
345
- "details": details,
346
- "features": features,
 
347
  }
348
 
349
  def _build_details(
@@ -353,9 +382,26 @@ class AudioReportAgent:
353
  features: dict,
354
  model_prob: float,
355
  heur_prob: float,
 
356
  ) -> list[str]:
357
  details = []
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  if is_fake:
360
  if prob > 0.85:
361
  details.append("High-confidence AI-generated voice detected")
@@ -366,17 +412,26 @@ class AudioReportAgent:
366
 
367
  pitch_std = features.get("pitch_std_hz")
368
  if pitch_std is not None and pitch_std < 15:
369
- details.append(f"Unnaturally stable pitch (Οƒ={pitch_std}Hz) β€” human speech typically varies 20-80Hz")
 
 
 
370
 
371
  delta_var = features.get("mfcc_delta_var")
372
  if delta_var is not None and delta_var < 1.5:
373
- details.append("Insufficient micro-variation in articulation β€” characteristic of TTS synthesis")
 
 
 
374
 
375
  silence = features.get("silence_ratio")
376
  if silence is not None and silence < 0.05:
377
- details.append("No natural breath pauses detected β€” AI voices lack organic speech rhythm")
 
 
 
378
 
379
- details.append(f"Wav2Vec2 model confidence: {model_prob*100:.1f}% synthetic")
380
  else:
381
  if prob < 0.25:
382
  details.append("Strong indicators of authentic human voice")
@@ -389,9 +444,11 @@ class AudioReportAgent:
389
 
390
  silence = features.get("silence_ratio")
391
  if silence is not None and 0.05 <= silence <= 0.35:
392
- details.append("Natural speech rhythm with organic pauses and breath sounds")
 
 
393
 
394
- details.append(f"Wav2Vec2 model confidence: {(1-model_prob)*100:.1f}% human")
395
 
396
  return details
397
 
@@ -406,7 +463,7 @@ class AudioAuthenticator:
406
  self.decision = AudioDecisionAgent()
407
  self.reporter = AudioReportAgent()
408
 
409
- def analyze(self, video_path: str) -> dict:
410
  # Step 1: Extract audio
411
  waveform, sr = self.extractor.extract(video_path)
412
 
@@ -419,5 +476,8 @@ class AudioAuthenticator:
419
  # Step 3: Model prediction
420
  model_prob = self.decision.predict(waveform, sr)
421
 
422
- # Step 4: Report
423
- return self.reporter.generate(model_prob, heuristic, has_audio=True)
 
 
 
 
221
  # Wav2Vec2 model for AI voice detection
222
  # ─────────────────────────────────────────────
223
  class AudioDecisionAgent:
224
+ # Primary: ASVspoof-trained model with bonafide/spoof labels
225
+ MODEL_ID = "Vansh180/deepfake-audio-wav2vec2"
226
+ CHUNK_SEC = 10
227
  TARGET_SR = 16000
228
 
229
  def __init__(self):
230
  self.model = None
231
  self.processor = None
232
+ self.fake_idx = 1 # default: label 1 = spoof/fake
233
  self.available = False
234
  self._load()
235
 
236
  def _load(self):
237
  try:
238
  from transformers import (
239
+ AutoModelForAudioClassification,
240
+ AutoFeatureExtractor,
241
  )
242
  logger.info(f"Loading audio model: {self.MODEL_ID}")
243
+ self.processor = AutoFeatureExtractor.from_pretrained(self.MODEL_ID)
244
+ self.model = AutoModelForAudioClassification.from_pretrained(self.MODEL_ID)
245
  self.model.eval()
246
 
247
+ # Find fake/spoof label index
248
  for idx, lbl in self.model.config.id2label.items():
249
+ lbl_lower = lbl.lower()
250
+ if any(w in lbl_lower for w in ("fake", "spoof", "synthetic", "generated")):
251
  self.fake_idx = idx
252
  break
253
 
254
  self.available = True
255
+ logger.info(
256
+ f"Audio model loaded β€” labels={self.model.config.id2label} "
257
+ f"fake_idx={self.fake_idx}"
258
+ )
259
  except Exception as e:
260
  logger.warning(f"Audio model unavailable: {e}")
261
  self.available = False
262
 
263
  def predict(self, waveform: np.ndarray, sr: int) -> float:
264
+ """Run model on audio chunks, return mean fake probability."""
 
 
265
  if not self.available:
266
  return 0.5
267
 
 
271
  chunks = [
272
  waveform[i : i + chunk_size]
273
  for i in range(0, len(waveform), chunk_size)
274
+ if len(waveform[i : i + chunk_size]) > sr // 2
275
  ]
276
 
277
  if not chunks:
 
313
  model_prob: float,
314
  heuristic: dict,
315
  has_audio: bool,
316
+ visual_fake_prob: float = 0.5,
317
  ) -> dict:
318
  if not has_audio:
319
  return {
 
333
  else:
334
  combined = model_prob
335
 
336
+ # ── Audio-Visual Mismatch Boost ───────────────────────────────
337
+ # Key insight: in face-swap deepfakes, the FACE is fake but the
338
+ # VOICE is real (dubbed from original footage). This mismatch
339
+ # is itself a strong deepfake signal.
340
+ # If visual says FAKE (high prob) but audio says HUMAN β†’ mismatch
341
+ av_mismatch = False
342
+ av_mismatch_score = 0.0
343
+ if visual_fake_prob >= 0.55 and model_prob < 0.50:
344
+ # Visual strongly fake, audio sounds human β†’ classic face-swap
345
+ av_mismatch = True
346
+ av_mismatch_score = visual_fake_prob * 0.6
347
+ # Boost audio fake probability to reflect the mismatch
348
+ combined = max(combined, av_mismatch_score)
349
+ logger.info(
350
+ f"Audio-visual mismatch detected: visual_fake={visual_fake_prob:.2f} "
351
+ f"audio_fake={model_prob:.2f} β†’ boosted to {combined:.2f}"
352
+ )
353
+
354
  combined = float(np.clip(combined, 0.0, 1.0))
355
  is_fake = combined >= self.FAKE_THRESHOLD
356
  confidence = round(combined * 100, 1)
357
 
358
+ details = self._build_details(
359
+ combined, is_fake, features, model_prob, heur_prob, av_mismatch
360
+ )
361
+
362
+ result_label = "AI_VOICE" if is_fake else "HUMAN_VOICE"
363
+ if av_mismatch:
364
+ result_label = "AV_MISMATCH" # special label for face-swap case
365
 
366
  return {
367
+ "available": True,
368
+ "result": result_label,
369
+ "confidence": confidence,
370
  "fake_probability": round(combined, 4),
371
+ "model_score": round(model_prob * 100, 1),
372
+ "heuristic_score": round(heur_prob * 100, 1),
373
+ "av_mismatch": av_mismatch,
374
+ "details": details,
375
+ "features": features,
376
  }
377
 
378
  def _build_details(
 
382
  features: dict,
383
  model_prob: float,
384
  heur_prob: float,
385
+ av_mismatch: bool = False,
386
  ) -> list[str]:
387
  details = []
388
 
389
+ # Audio-visual mismatch is the most important signal
390
+ if av_mismatch:
391
+ details.append(
392
+ "⚠️ Audio-visual mismatch detected β€” face appears manipulated but voice is human. "
393
+ "This is the hallmark of face-swap deepfakes where original audio is preserved."
394
+ )
395
+ details.append(
396
+ "Voice is authentic human speech, but does NOT match the manipulated face β€” "
397
+ "consistent with dubbed deepfake video (e.g. movie scene re-faced)"
398
+ )
399
+ details.append(
400
+ f"Visual deepfake confidence was high while voice model scored {(1-model_prob)*100:.1f}% human β€” "
401
+ "strong indicator of face-swap rather than full synthesis"
402
+ )
403
+ return details
404
+
405
  if is_fake:
406
  if prob > 0.85:
407
  details.append("High-confidence AI-generated voice detected")
 
412
 
413
  pitch_std = features.get("pitch_std_hz")
414
  if pitch_std is not None and pitch_std < 15:
415
+ details.append(
416
+ f"Unnaturally stable pitch (Οƒ={pitch_std}Hz) β€” "
417
+ "human speech typically varies 20-80Hz"
418
+ )
419
 
420
  delta_var = features.get("mfcc_delta_var")
421
  if delta_var is not None and delta_var < 1.5:
422
+ details.append(
423
+ "Insufficient micro-variation in articulation β€” "
424
+ "characteristic of TTS synthesis"
425
+ )
426
 
427
  silence = features.get("silence_ratio")
428
  if silence is not None and silence < 0.05:
429
+ details.append(
430
+ "No natural breath pauses detected β€” "
431
+ "AI voices lack organic speech rhythm"
432
+ )
433
 
434
+ details.append(f"ASVspoof model confidence: {model_prob*100:.1f}% synthetic")
435
  else:
436
  if prob < 0.25:
437
  details.append("Strong indicators of authentic human voice")
 
444
 
445
  silence = features.get("silence_ratio")
446
  if silence is not None and 0.05 <= silence <= 0.35:
447
+ details.append(
448
+ "Natural speech rhythm with organic pauses and breath sounds"
449
+ )
450
 
451
+ details.append(f"ASVspoof model confidence: {(1-model_prob)*100:.1f}% human")
452
 
453
  return details
454
 
 
463
  self.decision = AudioDecisionAgent()
464
  self.reporter = AudioReportAgent()
465
 
466
+ def analyze(self, video_path: str, visual_fake_prob: float = 0.5) -> dict:
467
  # Step 1: Extract audio
468
  waveform, sr = self.extractor.extract(video_path)
469
 
 
476
  # Step 3: Model prediction
477
  model_prob = self.decision.predict(waveform, sr)
478
 
479
+ # Step 4: Report (pass visual prob for mismatch detection)
480
+ return self.reporter.generate(
481
+ model_prob, heuristic, has_audio=True,
482
+ visual_fake_prob=visual_fake_prob,
483
+ )
backend/detector.py CHANGED
@@ -623,12 +623,13 @@ class DeepfakeAuthenticator:
623
  # Step 3: Visual decision
624
  analysis = self.decision_agent.analyze_frames(frames, face_crops_per_frame)
625
 
626
- # Step 4: Audio analysis (parallel-ish β€” runs after visual)
627
  audio_result = {"available": False, "result": "NO_AUDIO", "confidence": 0, "details": []}
628
  audio_agent = self._get_audio()
629
  if audio_agent:
630
  try:
631
- audio_result = audio_agent.analyze(video_path)
 
632
  except Exception as e:
633
  logger.warning(f"Audio analysis failed: {e}")
634
 
 
623
  # Step 3: Visual decision
624
  analysis = self.decision_agent.analyze_frames(frames, face_crops_per_frame)
625
 
626
+ # Step 4: Audio analysis β€” pass visual prob for mismatch detection
627
  audio_result = {"available": False, "result": "NO_AUDIO", "confidence": 0, "details": []}
628
  audio_agent = self._get_audio()
629
  if audio_agent:
630
  try:
631
+ visual_prob = analysis.get("overall_fake_probability", 0.5)
632
+ audio_result = audio_agent.analyze(video_path, visual_fake_prob=visual_prob)
633
  except Exception as e:
634
  logger.warning(f"Audio analysis failed: {e}")
635
 
frontend-vanilla/index.html CHANGED
@@ -223,21 +223,24 @@
223
  }
224
  .tl-tooltip {
225
  position: absolute;
226
- bottom: calc(100% + 6px);
227
  left: 50%;
228
  transform: translateX(-50%);
229
- background: rgba(10,10,20,0.95);
230
- border: 1px solid rgba(255,255,255,0.1);
231
  border-radius: 4px;
232
- padding: 3px 7px;
233
- font-size: 10px;
 
234
  white-space: nowrap;
235
  pointer-events: none;
236
  opacity: 0;
237
- transition: all 0.2s ease;
238
- z-index: 20;
239
- font-family: 'Space Grotesk', sans-serif;
240
- color: #dae5da;
 
 
241
  }
242
 
243
  /* ── Scrollbar ── */
@@ -668,7 +671,7 @@
668
  <span class="font-data-mono text-[9px] text-outline">0%</span>
669
  </div>
670
  <!-- Bars container -->
671
- <div id="timelineBars" class="h-[160px] flex items-end gap-[2px] border-b border-l border-surface-variant pl-8 relative z-10 overflow-hidden">
672
  <!-- Populated by JS -->
673
  </div>
674
  </div>
@@ -1147,19 +1150,19 @@ function renderInsightCards(details, isFake, conf) {
1147
  function renderMetaTerminal(data) {
1148
  const meta = data.metadata || {};
1149
  const rows = [
1150
- ['RESULT', data.result || '--'],
1151
- ['FRAMES_ANALYZED', meta.frames_analyzed ?? '--'],
1152
- ['FACES_DETECTED', meta.frames_with_faces ?? '--'],
1153
- ['DURATION', meta.video_duration_sec != null ? meta.video_duration_sec.toFixed(1) + 's' : '--'],
1154
- ['FPS', meta.video_fps ?? '--'],
1155
- ['RESOLUTION', meta.resolution || '--'],
1156
- ['PROC_TIME', data.processing_time_sec != null ? data.processing_time_sec.toFixed(2) + 's' : '--'],
1157
  ];
1158
 
1159
  const html = rows.map(([k, v]) => `
1160
- <div class="flex">
1161
- <span class="text-outline w-36 shrink-0">${k}:</span>
1162
- <span class="text-on-surface">${escHtml(String(v))}</span>
1163
  </div>`).join('') + `
1164
  <div class="my-3 border-t border-surface-variant/30 border-dashed"></div>
1165
  <div class="text-primary-container opacity-80">&gt; INIT NEURAL_NET_V4.2</div>
@@ -1189,17 +1192,19 @@ function renderTimeline(frames) {
1189
  const maxH = 140; // px
1190
 
1191
  container.innerHTML = subset.map(f => {
1192
- const pct = typeof f.fake_pct === 'number' ? f.fake_pct : 0;
1193
- const heightPct = Math.max(1.5, pct);
1194
- const isHigh = pct > 60;
1195
- const barBg = isHigh ? '#ffb4ab' : '#00ff9c';
1196
- const barGlow = isHigh
1197
- ? 'box-shadow:0 0 8px rgba(255,180,171,0.5);'
1198
- : 'box-shadow:0 0 6px rgba(0,255,156,0.3);';
 
 
1199
  return `
1200
- <div class="tl-bar" style="height:${maxH}px;position:relative;">
1201
- <div class="tl-tooltip">F${f.frame}: ${pct.toFixed(1)}%</div>
1202
- <div style="position:absolute;bottom:0;left:0;right:0;height:${heightPct}%;background:${barBg};${barGlow}border-radius:2px 2px 0 0;"></div>
1203
  </div>`;
1204
  }).join('');
1205
 
@@ -1212,18 +1217,22 @@ function renderTimeline(frames) {
1212
 
1213
  // ── Audio section ─────────────────────────────────────────────────────────────
1214
  function renderAudio(audio) {
1215
- const isAI = audio.result === 'AI_VOICE';
 
 
1216
 
1217
  const icon = document.getElementById('audioIcon');
1218
- icon.textContent = isAI ? 'smart_toy' : 'record_voice_over';
1219
- icon.style.color = isAI ? '#ffb4ab' : '#00ff9c';
1220
 
1221
  const verdict = document.getElementById('audioVerdict');
1222
- verdict.textContent = audio.result || '--';
1223
- verdict.style.color = isAI ? '#ffb4ab' : '#00ff9c';
1224
 
1225
  document.getElementById('audioConfidence').textContent =
1226
- `Confidence: ${typeof audio.confidence === 'number' ? audio.confidence.toFixed(1) + '%' : '--'}`;
 
 
1227
 
1228
  const modelScore = typeof audio.model_score === 'number' ? audio.model_score : 0;
1229
  const heurScore = typeof audio.heuristic_score === 'number' ? audio.heuristic_score : 0;
@@ -1233,13 +1242,14 @@ function renderAudio(audio) {
1233
  document.getElementById('audioHeuristicBar').style.width = heurScore + '%';
1234
  document.getElementById('audioHeuristicScore').textContent = heurScore.toFixed(1) + '%';
1235
 
1236
- // Audio details
1237
  const detailsEl = document.getElementById('audioDetails');
1238
  if (audio.details && audio.details.length) {
1239
  detailsEl.innerHTML = audio.details.map(d => `
1240
- <div class="flex items-start gap-3 p-3 bg-surface-container-low/50 border border-outline-variant/30 rounded-DEFAULT">
1241
- <span class="material-symbols-outlined text-on-surface-variant text-[16px] mt-0.5">chevron_right</span>
1242
- <span class="font-data-mono text-data-mono text-on-surface-variant text-sm">${escHtml(d)}</span>
 
1243
  </div>`).join('');
1244
  } else {
1245
  detailsEl.innerHTML = '';
 
223
  }
224
  .tl-tooltip {
225
  position: absolute;
226
+ bottom: calc(100% + 8px);
227
  left: 50%;
228
  transform: translateX(-50%);
229
+ background: #0a0f0a;
230
+ border: 1px solid #00ff9c;
231
  border-radius: 4px;
232
+ padding: 4px 8px;
233
+ font-size: 11px;
234
+ font-weight: 700;
235
  white-space: nowrap;
236
  pointer-events: none;
237
  opacity: 0;
238
+ transition: opacity 0.2s ease, transform 0.2s ease;
239
+ z-index: 100;
240
+ font-family: 'Space Grotesk', monospace;
241
+ color: #ffffff !important;
242
+ letter-spacing: 0.05em;
243
+ box-shadow: 0 0 10px rgba(0,255,156,0.3);
244
  }
245
 
246
  /* ── Scrollbar ── */
 
671
  <span class="font-data-mono text-[9px] text-outline">0%</span>
672
  </div>
673
  <!-- Bars container -->
674
+ <div id="timelineBars" class="flex items-end gap-[2px] border-b border-l border-surface-variant pl-8 relative z-10" style="height:160px;overflow:visible;">
675
  <!-- Populated by JS -->
676
  </div>
677
  </div>
 
1150
  function renderMetaTerminal(data) {
1151
  const meta = data.metadata || {};
1152
  const rows = [
1153
+ ['RESULT', data.result || '--'],
1154
+ ['FRAMES_ANALYZED', meta.frames_analyzed ?? '--'],
1155
+ ['FACES_DETECTED', meta.frames_with_faces ?? '--'],
1156
+ ['DURATION', meta.video_duration_sec != null ? meta.video_duration_sec.toFixed(1) + 's' : '--'],
1157
+ ['FPS', meta.video_fps ?? '--'],
1158
+ ['RESOLUTION', meta.resolution || '--'],
1159
+ ['PROC_TIME', data.processing_time_sec != null ? data.processing_time_sec.toFixed(2) + 's' : '--'],
1160
  ];
1161
 
1162
  const html = rows.map(([k, v]) => `
1163
+ <div class="flex items-baseline gap-0">
1164
+ <span class="text-outline shrink-0" style="width:11rem;min-width:11rem;">${k}:</span>
1165
+ <span class="text-on-surface font-semibold">${escHtml(String(v))}</span>
1166
  </div>`).join('') + `
1167
  <div class="my-3 border-t border-surface-variant/30 border-dashed"></div>
1168
  <div class="text-primary-container opacity-80">&gt; INIT NEURAL_NET_V4.2</div>
 
1192
  const maxH = 140; // px
1193
 
1194
  container.innerHTML = subset.map(f => {
1195
+ const pct = typeof f.fake_pct === 'number' ? f.fake_pct : 0;
1196
+ const barH = Math.max(3, Math.round((pct / 100) * maxH)); // px, not %
1197
+ const isHigh = pct >= 60;
1198
+ const barBg = isHigh ? '#ffb4ab' : '#00ff9c';
1199
+ const barGlow = isHigh
1200
+ ? 'box-shadow:0 0 8px rgba(255,180,171,0.6);'
1201
+ : 'box-shadow:0 0 6px rgba(0,255,156,0.4);';
1202
+ const tipLabel = isHigh ? `⚠ ${pct.toFixed(1)}%` : `βœ“ ${pct.toFixed(1)}%`;
1203
+ const tipBorder = isHigh ? '#ffb4ab' : '#00ff9c';
1204
  return `
1205
+ <div class="tl-bar" style="height:${maxH}px;position:relative;flex:1;min-width:6px;max-width:20px;">
1206
+ <div class="tl-tooltip" style="border-color:${tipBorder};">${tipLabel}</div>
1207
+ <div style="position:absolute;bottom:0;left:1px;right:1px;height:${barH}px;background:${barBg};${barGlow}border-radius:2px 2px 0 0;"></div>
1208
  </div>`;
1209
  }).join('');
1210
 
 
1217
 
1218
  // ── Audio section ─────────────────────────────────────────────────────────────
1219
  function renderAudio(audio) {
1220
+ const isAI = audio.result === 'AI_VOICE';
1221
+ const isMismatch = audio.result === 'AV_MISMATCH';
1222
+ const color = isMismatch ? '#ffdd65' : (isAI ? '#ffb4ab' : '#00ff9c');
1223
 
1224
  const icon = document.getElementById('audioIcon');
1225
+ icon.textContent = isMismatch ? 'warning' : (isAI ? 'smart_toy' : 'record_voice_over');
1226
+ icon.style.color = color;
1227
 
1228
  const verdict = document.getElementById('audioVerdict');
1229
+ verdict.textContent = isMismatch ? '⚠ AV MISMATCH' : (audio.result || '--');
1230
+ verdict.style.color = color;
1231
 
1232
  document.getElementById('audioConfidence').textContent =
1233
+ isMismatch
1234
+ ? 'Face-swap: voice is human but face is manipulated'
1235
+ : `Confidence: ${typeof audio.confidence === 'number' ? audio.confidence.toFixed(1) + '%' : '--'}`;
1236
 
1237
  const modelScore = typeof audio.model_score === 'number' ? audio.model_score : 0;
1238
  const heurScore = typeof audio.heuristic_score === 'number' ? audio.heuristic_score : 0;
 
1242
  document.getElementById('audioHeuristicBar').style.width = heurScore + '%';
1243
  document.getElementById('audioHeuristicScore').textContent = heurScore.toFixed(1) + '%';
1244
 
1245
+ // Audio details β€” highlight mismatch details in yellow
1246
  const detailsEl = document.getElementById('audioDetails');
1247
  if (audio.details && audio.details.length) {
1248
  detailsEl.innerHTML = audio.details.map(d => `
1249
+ <div class="flex items-start gap-3 p-3 bg-surface-container-low/50 border border-outline-variant/30 rounded-DEFAULT"
1250
+ style="${isMismatch ? 'border-color:rgba(255,221,101,0.3);background:rgba(255,221,101,0.05);' : ''}">
1251
+ <span class="material-symbols-outlined text-[16px] mt-0.5" style="color:${color};">${isMismatch ? 'warning' : 'chevron_right'}</span>
1252
+ <span class="font-data-mono text-data-mono text-sm" style="color:${isMismatch ? '#ffdd65cc' : '#b9cbbc'};">${escHtml(d)}</span>
1253
  </div>`).join('');
1254
  } else {
1255
  detailsEl.innerHTML = '';