Spaces:
Running
Running
| import { API_BASE_URL } from '../config'; | |
| const API_BASE = `${API_BASE_URL}/audio`; | |
| export interface AudioSignalScores { | |
| wavlm: number; | |
| wav2vec: number; | |
| spectral: number; | |
| prosody: number; | |
| speaker: number; | |
| codec: number; | |
| } | |
| export interface AudioTimelineSegment { | |
| segment: number; | |
| start_sec: number; | |
| end_sec: number; | |
| ai_score: number; | |
| level: "low" | "medium" | "high" | "critical"; | |
| signals: AudioSignalScores; | |
| } | |
| export interface AudioReason { | |
| signal: string; | |
| severity: string; | |
| message: string; | |
| evidence: string; | |
| score: number; | |
| } | |
| export interface AudioMetadata { | |
| duration_sec: number; | |
| num_chunks: number; | |
| format_hint: string; | |
| file_size_bytes: number; | |
| } | |
| export interface WavLMDetail { | |
| max?: number; | |
| var?: number; | |
| model?: string; | |
| reason?: string; | |
| } | |
| export interface Wav2VecDetail { | |
| max_chunk_score?: number; | |
| min_chunk_score?: number; | |
| score_variance?: number; | |
| chunks_analyzed?: number; | |
| ai_label_index?: number; | |
| reason?: string; | |
| } | |
| export interface ProsodyDetail { | |
| f0_std_semitones?: number; | |
| f0_range_semitones?: number; | |
| f0_kurtosis?: number; | |
| rhythm_ioi_cv?: number; | |
| pause_cv?: number; | |
| speaking_rate_variance?: number; | |
| voiced_frame_count?: number; | |
| reason?: string; | |
| } | |
| export interface SpeakerDetail { | |
| mean_sim?: number; | |
| std_sim?: number; | |
| min_sim?: number; | |
| identity_jumps?: number; | |
| is_unnatural_constancy?: boolean; | |
| is_identity_drift?: boolean; | |
| reason?: string; | |
| } | |
| export interface SpectralDetail { | |
| mfcc_delta_std?: number; | |
| flatness_variance?: number; | |
| flatness_mean?: number; | |
| hf_energy_ratio?: number; | |
| lf_energy_ratio?: number; | |
| centroid_variance?: number; | |
| mel_frame_diff?: number; | |
| global_score?: number; | |
| chunk_mean?: number; | |
| reason?: string; | |
| } | |
| export interface CodecDetail { | |
| snr_proxy_db?: number; | |
| dc_offset?: number; | |
| spectral_ripple_peaks?: number; | |
| enf_strength?: number; | |
| near_clip_ratio?: number; | |
| lp_residual_variance?: number; | |
| sub_scores?: { | |
| snr?: number; | |
| dc_offset?: number; | |
| spectral_ripple?: number; | |
| enf?: number; | |
| clipping?: number; | |
| dithering?: number; | |
| }; | |
| reason?: string; | |
| } | |
| export interface RobustnessReport { | |
| stability_score: number; | |
| is_stable: boolean; | |
| scores: { | |
| original: number; | |
| telephony: number; | |
| compressed: number; | |
| }; | |
| max_delta: number; | |
| variance: number; | |
| } | |
| export interface AudioResult { | |
| verdict: string; | |
| threat_level: "CRITICAL" | "HIGH" | "MEDIUM" | "LOW"; | |
| ai_probability: number; | |
| confidence: "LOW" | "MEDIUM" | "HIGH"; | |
| agreement: string; | |
| fusion_rule: string; | |
| forensic_summary: string; | |
| recommended_action: string; | |
| primary_reasons: AudioReason[]; | |
| supporting_reasons: AudioReason[]; | |
| exonerating_factors: string[]; | |
| audio_metadata: AudioMetadata; | |
| timeline: AudioTimelineSegment[]; | |
| signal_scores: AudioSignalScores; | |
| stability_score: number; | |
| stability_report: RobustnessReport; | |
| signal_details: { | |
| wavlm: WavLMDetail; | |
| wav2vec: Wav2VecDetail; | |
| spectral: SpectralDetail; | |
| prosody: ProsodyDetail; | |
| speaker: SpeakerDetail; | |
| codec: CodecDetail; | |
| }; | |
| } | |
| // Allowed MIME types and extensions | |
| const ALLOWED_EXTENSIONS = ["wav", "mp3", "flac", "ogg", "m4a", "mp4"]; | |
| function getFileExtension(filename: string): string { | |
| const parts = filename.split("."); | |
| return parts.length > 1 ? parts[parts.length - 1].toLowerCase() : ""; | |
| } | |
| export function isValidAudioFile(file: File): { valid: boolean; error?: string } { | |
| const ext = getFileExtension(file.name); | |
| if (!ALLOWED_EXTENSIONS.includes(ext)) { | |
| return { valid: false, error: `Unsupported format ".${ext}". Supported: WAV, MP3, FLAC, OGG, M4A` }; | |
| } | |
| if (file.size > 50 * 1024 * 1024) { | |
| return { valid: false, error: "File too large. Maximum 50MB." }; | |
| } | |
| if (file.size === 0) { | |
| return { valid: false, error: "File is empty." }; | |
| } | |
| return { valid: true }; | |
| } | |
| export async function analyzeAudioAsync( | |
| file: File, | |
| token: string | null = null, | |
| onProgress?: (status: string) => void | |
| ): Promise<AudioResult> { | |
| // Validate before upload | |
| const validation = isValidAudioFile(file); | |
| if (!validation.valid) { | |
| throw new Error(validation.error); | |
| } | |
| // Step 1: Upload | |
| onProgress?.("Uploading audio for forensic scanning..."); | |
| const formData = new FormData(); | |
| // Append with explicit filename to ensure server receives it | |
| formData.append("file", file, file.name); | |
| let submitRes: Response; | |
| try { | |
| submitRes = await fetch(`${API_BASE}/analyze/async`, { | |
| method: "POST", | |
| headers: { | |
| ...(token ? { "Authorization": `Bearer ${token}` } : {}) | |
| }, | |
| body: formData, | |
| // Do NOT set Content-Type header — browser sets it with boundary automatically | |
| }); | |
| } catch (networkErr: any) { | |
| throw new Error(`Cannot reach forensic backend at ${API_BASE}. Is the server running? (${networkErr.message})`); | |
| } | |
| if (!submitRes.ok) { | |
| let errDetail = `Upload failed (HTTP ${submitRes.status})`; | |
| try { | |
| const errBody = await submitRes.json(); | |
| errDetail = errBody.detail || errDetail; | |
| } catch { | |
| try { | |
| errDetail = await submitRes.text() || errDetail; | |
| } catch { /* ignore */ } | |
| } | |
| throw new Error(errDetail); | |
| } | |
| const submitData = await submitRes.json(); | |
| const job_id: string = submitData.job_id; | |
| if (!job_id) { | |
| throw new Error("Server did not return a job ID. Check backend logs."); | |
| } | |
| // Step 2: Poll for completion | |
| const messages = [ | |
| "Running WavLM in-the-wild deepfake classifier...", | |
| "Running Wav2Vec2 ASVspoof classifier...", | |
| "Analyzing spectral artifacts & MFCCs...", | |
| "Computing prosody & rhythmic regularity...", | |
| "Measuring intra-speaker identity drift...", | |
| "Scanning for codec & resampling signatures...", | |
| "Running robustness stress-test...", | |
| "Fusing signals with adaptive engine...", | |
| "Building temporal forensic timeline...", | |
| "Generating human-readable explanations...", | |
| ]; | |
| let msgIdx = 0; | |
| return new Promise((resolve, reject) => { | |
| let solved = false; | |
| const interval = setInterval(async () => { | |
| if (solved) return; | |
| try { | |
| onProgress?.(messages[msgIdx % messages.length]); | |
| msgIdx++; | |
| const statusRes = await fetch(`${API_BASE}/status/${job_id}`, { | |
| headers: { | |
| ...(token ? { "Authorization": `Bearer ${token}` } : {}) | |
| } | |
| }); | |
| if (!statusRes.ok) { | |
| if (statusRes.status === 404) { | |
| // Job expired or doesn't exist | |
| solved = true; | |
| clearInterval(interval); | |
| reject(new Error("Analysis job not found on server. Please try again.")); | |
| return; | |
| } | |
| // Transient error — keep polling | |
| return; | |
| } | |
| const status = await statusRes.json(); | |
| if (status.status === "complete") { | |
| solved = true; | |
| clearInterval(interval); | |
| if (!status.result) { | |
| reject(new Error("Server returned empty result. Check backend logs.")); | |
| return; | |
| } | |
| resolve(status.result as AudioResult); | |
| } else if (status.status === "error") { | |
| solved = true; | |
| clearInterval(interval); | |
| reject(new Error(status.error || "Backend analysis failed. Check server logs.")); | |
| } | |
| // status "processing" → keep polling | |
| } catch (e: any) { | |
| // Network error during polling — don't abort, just log and retry | |
| console.warn("Poll attempt failed, retrying:", e?.message); | |
| } | |
| }, 2500); | |
| // Timeout after 180s — audio forensics with multiple models can take time | |
| setTimeout(() => { | |
| if (!solved) { | |
| solved = true; | |
| clearInterval(interval); | |
| reject(new Error("Audio forensic analysis timed out after 3 minutes. The file may be too long or server is overloaded.")); | |
| } | |
| }, 180000); | |
| }); | |
| } | |