""" DSP Feature Extraction v2 — Expanded feature set for AI Voice Detection ======================================================================== Features: ~85 total (compared to 37 in v1) New in v2: - Delta & Delta-Delta MFCC (temporal dynamics) - Spectral bandwidth, contrast, tonnetz - Jitter & Shimmer (micro pitch/amplitude perturbations) - Harmonic-to-Noise Ratio (HNR) - Formant frequencies (F1-F4) - Spectral skewness, kurtosis, entropy - Silence ratio & pause patterns - Temporal envelope modulation Requires: librosa, numpy, scipy Optional: parselmouth (for jitter, shimmer, HNR, formants) """ import os import numpy as np import librosa import pandas as pd from scipy import stats as scipy_stats from scipy.signal import hilbert from tqdm import tqdm import sys import warnings warnings.filterwarnings("ignore") # Add src to path sys.path.append(os.path.join(os.path.dirname(__file__), '../..')) from src.config import DATA_DIR, SAMPLE_RATE # Try importing parselmouth for voice quality features try: import parselmouth from parselmouth.praat import call HAS_PARSELMOUTH = True except ImportError: HAS_PARSELMOUTH = False print("WARNING: parselmouth not installed. Jitter/Shimmer/HNR/Formant features will be zeros.") print(" Install with: pip install praat-parselmouth") # ============================================================ # Feature Extraction Functions # ============================================================ def extract_mfcc_features(y, sr, n_mfcc=13): """ MFCC + Delta + Delta-Delta Returns: 80 features (13 * 2 * 3 + 2 overall) """ features = {} mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=n_mfcc) delta_mfcc = librosa.feature.delta(mfcc, order=1) delta2_mfcc = librosa.feature.delta(mfcc, order=2) # Overall MFCC stats features['mfcc_mean'] = np.mean(mfcc) features['mfcc_var'] = np.var(mfcc) # Per-coefficient stats for MFCC, Delta, Delta-Delta for i in range(n_mfcc): features[f'mfcc_{i+1}_mean'] = np.mean(mfcc[i]) features[f'mfcc_{i+1}_var'] = np.var(mfcc[i]) features[f'delta_mfcc_{i+1}_mean'] = np.mean(delta_mfcc[i]) features[f'delta_mfcc_{i+1}_var'] = np.var(delta_mfcc[i]) features[f'delta2_mfcc_{i+1}_mean'] = np.mean(delta2_mfcc[i]) features[f'delta2_mfcc_{i+1}_var'] = np.var(delta2_mfcc[i]) return features def extract_spectral_features(y, sr): """ Spectral: centroid, bandwidth, flatness, rolloff, contrast, tonnetz Returns: ~24 features """ features = {} # Spectral Centroid spec_cent = librosa.feature.spectral_centroid(y=y, sr=sr) features['spec_cent_mean'] = np.mean(spec_cent) features['spec_cent_var'] = np.var(spec_cent) # Spectral Bandwidth (NEW in v2) spec_bw = librosa.feature.spectral_bandwidth(y=y, sr=sr) features['spec_bw_mean'] = np.mean(spec_bw) features['spec_bw_var'] = np.var(spec_bw) # Spectral Flatness spec_flat = librosa.feature.spectral_flatness(y=y) features['spec_flat_mean'] = np.mean(spec_flat) features['spec_flat_var'] = np.var(spec_flat) # Spectral Rolloff spec_roll = librosa.feature.spectral_rolloff(y=y, sr=sr) features['spec_roll_mean'] = np.mean(spec_roll) features['spec_roll_var'] = np.var(spec_roll) # Spectral Contrast — 7 bands (NEW in v2) spec_contrast = librosa.feature.spectral_contrast(y=y, sr=sr, n_bands=6) for i in range(7): # 6 bands + 1 valley features[f'spec_contrast_{i}_mean'] = np.mean(spec_contrast[i]) # Tonnetz — 6 tonal features (NEW in v2) # Requires harmonic component y_harmonic = librosa.effects.harmonic(y) tonnetz = librosa.feature.tonnetz(y=y_harmonic, sr=sr) for i in range(6): features[f'tonnetz_{i}_mean'] = np.mean(tonnetz[i]) return features def extract_energy_rhythm_features(y, sr): """ RMS energy, ZCR, Chroma, Tempo Returns: ~6 features """ features = {} # RMS Energy rms = librosa.feature.rms(y=y) features['rms_mean'] = np.mean(rms) features['rms_var'] = np.var(rms) # Zero Crossing Rate zcr = librosa.feature.zero_crossing_rate(y) features['zcr_mean'] = np.mean(zcr) features['zcr_var'] = np.var(zcr) # Chroma chroma = librosa.feature.chroma_stft(y=y, sr=sr) features['chroma_mean'] = np.mean(chroma) features['chroma_var'] = np.var(chroma) return features def extract_pitch_features(y, sr): """ Pitch (F0) statistics using librosa piptrack Returns: 4 features """ features = {} pitches, magnitudes = librosa.piptrack(y=y, sr=sr) pitches_filtered = pitches[magnitudes > np.median(magnitudes)] if len(pitches_filtered) > 0: pitches_nonzero = pitches_filtered[pitches_filtered > 0] if len(pitches_nonzero) > 0: features['pitch_mean'] = np.mean(pitches_nonzero) features['pitch_std'] = np.std(pitches_nonzero) features['pitch_range'] = np.ptp(pitches_nonzero) # max - min features['pitch_cv'] = np.std(pitches_nonzero) / (np.mean(pitches_nonzero) + 1e-8) # coefficient of variation else: features['pitch_mean'] = 0 features['pitch_std'] = 0 features['pitch_range'] = 0 features['pitch_cv'] = 0 else: features['pitch_mean'] = 0 features['pitch_std'] = 0 features['pitch_range'] = 0 features['pitch_cv'] = 0 return features def extract_voice_quality_features(y, sr): """ Jitter, Shimmer, HNR, Formants via parselmouth/Praat Returns: 10 features (or zeros if parselmouth not available) These are CRITICAL for AI voice detection: - Jitter: micro pitch perturbations (humans have them, AI doesn't) - Shimmer: micro amplitude perturbations (same) - HNR: how clean the voice is (AI is too clean) - Formants: vocal tract resonances (AI has unnatural transitions) """ features = { 'jitter_local': 0.0, 'jitter_rap': 0.0, 'jitter_ppq5': 0.0, 'shimmer_local': 0.0, 'shimmer_apq3': 0.0, 'shimmer_apq5': 0.0, 'hnr_mean': 0.0, 'formant_f1_mean': 0.0, 'formant_f2_mean': 0.0, 'formant_f3_mean': 0.0, } if not HAS_PARSELMOUTH: return features try: # Create Praat Sound object snd = parselmouth.Sound(y, sampling_frequency=sr) # --- Pitch Object (needed for jitter/shimmer) --- pitch = call(snd, "To Pitch", 0.0, 75, 600) # --- Point Process (needed for jitter/shimmer) --- point_process = call(snd, "To PointProcess (periodic, cc)", 75, 600) # --- Jitter --- try: features['jitter_local'] = call(point_process, "Get jitter (local)", 0, 0, 0.0001, 0.02, 1.3) except Exception: pass try: features['jitter_rap'] = call(point_process, "Get jitter (rap)", 0, 0, 0.0001, 0.02, 1.3) except Exception: pass try: features['jitter_ppq5'] = call(point_process, "Get jitter (ppq5)", 0, 0, 0.0001, 0.02, 1.3) except Exception: pass # --- Shimmer --- try: features['shimmer_local'] = call([snd, point_process], "Get shimmer (local)", 0, 0, 0.0001, 0.02, 1.3, 1.6) except Exception: pass try: features['shimmer_apq3'] = call([snd, point_process], "Get shimmer (apq3)", 0, 0, 0.0001, 0.02, 1.3, 1.6) except Exception: pass try: features['shimmer_apq5'] = call([snd, point_process], "Get shimmer (apq5)", 0, 0, 0.0001, 0.02, 1.3, 1.6) except Exception: pass # --- HNR (Harmonics-to-Noise Ratio) --- try: harmonicity = call(snd, "To Harmonicity (cc)", 0.01, 75, 0.1, 1.0) features['hnr_mean'] = call(harmonicity, "Get mean", 0, 0) if np.isnan(features['hnr_mean']): features['hnr_mean'] = 0.0 except Exception: pass # --- Formants (F1-F3) --- try: formant = call(snd, "To Formant (burg)", 0.0, 5, 5500, 0.025, 50) duration = snd.get_total_duration() f1_values, f2_values, f3_values = [], [], [] n_frames = call(formant, "Get number of frames") for frame in range(1, n_frames + 1): t = call(formant, "Get time from frame number", frame) f1 = call(formant, "Get value at time", 1, t, "Hertz", "Linear") f2 = call(formant, "Get value at time", 2, t, "Hertz", "Linear") f3 = call(formant, "Get value at time", 3, t, "Hertz", "Linear") if not np.isnan(f1): f1_values.append(f1) if not np.isnan(f2): f2_values.append(f2) if not np.isnan(f3): f3_values.append(f3) features['formant_f1_mean'] = np.mean(f1_values) if f1_values else 0.0 features['formant_f2_mean'] = np.mean(f2_values) if f2_values else 0.0 features['formant_f3_mean'] = np.mean(f3_values) if f3_values else 0.0 except Exception: pass except Exception as e: # If parselmouth fails entirely, all features stay at 0 pass # Replace any NaN with 0 for k, v in features.items(): if isinstance(v, float) and (np.isnan(v) or np.isinf(v)): features[k] = 0.0 return features def extract_spectral_stats(y, sr): """ Higher-order spectral statistics: skewness, kurtosis, entropy Returns: 3 features """ features = {} # Compute magnitude spectrum S = np.abs(librosa.stft(y)) S_mean = np.mean(S, axis=1) # Average across time # Normalize to probability distribution S_norm = S_mean / (np.sum(S_mean) + 1e-8) # Spectral Skewness — asymmetry of spectral distribution features['spec_skewness'] = float(scipy_stats.skew(S_norm)) # Spectral Kurtosis — peakedness of spectral distribution features['spec_kurtosis'] = float(scipy_stats.kurtosis(S_norm)) # Spectral Entropy — randomness/complexity of spectrum S_entropy = S_norm[S_norm > 0] features['spec_entropy'] = float(-np.sum(S_entropy * np.log2(S_entropy + 1e-12))) return features def extract_silence_features(y, sr, threshold_db=30): """ Silence/pause analysis — AI voices have mechanical pauses Returns: 3 features """ features = {} # Split audio into non-silent intervals intervals = librosa.effects.split(y, top_db=threshold_db) total_duration = len(y) / sr if len(intervals) > 0: # Total non-silent duration voiced_duration = sum((end - start) for start, end in intervals) / sr silence_duration = total_duration - voiced_duration features['silence_ratio'] = silence_duration / (total_duration + 1e-8) # Pause analysis (gaps between voiced segments) if len(intervals) > 1: pauses = [] for i in range(1, len(intervals)): pause = (intervals[i][0] - intervals[i-1][1]) / sr pauses.append(pause) features['pause_count'] = len(pauses) features['pause_mean_duration'] = np.mean(pauses) else: features['pause_count'] = 0 features['pause_mean_duration'] = 0.0 else: features['silence_ratio'] = 1.0 features['pause_count'] = 0 features['pause_mean_duration'] = 0.0 return features def extract_modulation_features(y, sr): """ Temporal envelope modulation — natural speech has ~4Hz modulation AI voices often lack this natural rhythm Returns: 2 features """ features = {} try: # Get amplitude envelope using Hilbert transform analytic_signal = hilbert(y) envelope = np.abs(analytic_signal) # Compute spectrum of the envelope n_fft = min(len(envelope), 4096) env_fft = np.abs(np.fft.rfft(envelope, n=n_fft)) freqs = np.fft.rfftfreq(n_fft, d=1.0/sr) # Energy in 2-8 Hz band (speech modulation range) mask_speech = (freqs >= 2) & (freqs <= 8) # Energy in 0-2 Hz band (baseline) mask_low = (freqs >= 0.1) & (freqs < 2) speech_mod_energy = np.mean(env_fft[mask_speech]) if np.any(mask_speech) else 0 low_energy = np.mean(env_fft[mask_low]) if np.any(mask_low) else 1e-8 # Modulation index: ratio of speech-rate modulation to baseline features['mod_index_4hz'] = float(speech_mod_energy / (low_energy + 1e-8)) # Peak modulation frequency if np.any(mask_speech): speech_freqs = freqs[mask_speech] speech_fft = env_fft[mask_speech] features['mod_peak_freq'] = float(speech_freqs[np.argmax(speech_fft)]) else: features['mod_peak_freq'] = 0.0 except Exception: features['mod_index_4hz'] = 0.0 features['mod_peak_freq'] = 0.0 return features # ============================================================ # Main Feature Extraction # ============================================================ def extract_all_features_v2(file_path, sr=SAMPLE_RATE): """ Extract all v2 DSP features from a single audio file. Returns: dict of ~85 features, or None on error """ try: y, sr = librosa.load(file_path, sr=sr) if len(y) < int(0.5 * sr): return None # Too short features = {} # 1. MFCC + Deltas (~80 features) features.update(extract_mfcc_features(y, sr)) # 2. Spectral (~24 features) features.update(extract_spectral_features(y, sr)) # 3. Energy & Rhythm (~6 features) features.update(extract_energy_rhythm_features(y, sr)) # 4. Pitch (~4 features) features.update(extract_pitch_features(y, sr)) # 5. Voice Quality: Jitter, Shimmer, HNR, Formants (~10 features) features.update(extract_voice_quality_features(y, sr)) # 6. Spectral Statistics (~3 features) features.update(extract_spectral_stats(y, sr)) # 7. Silence Analysis (~3 features) features.update(extract_silence_features(y, sr)) # 8. Temporal Modulation (~2 features) features.update(extract_modulation_features(y, sr)) # Sanitize: replace NaN/Inf with 0 for k, v in features.items(): if isinstance(v, (float, np.floating)): if np.isnan(v) or np.isinf(v): features[k] = 0.0 return features except Exception as e: print(f"Error extracting features for {file_path}: {e}") return None def get_feature_names(): """ Returns the ordered list of all v2 feature names. Useful for ensuring consistent column ordering. """ # Generate a dummy extraction to get all feature names dummy_y = np.random.randn(SAMPLE_RATE * 2) # 2s of noise features = extract_all_features_v2.__wrapped__(dummy_y, SAMPLE_RATE) if hasattr(extract_all_features_v2, '__wrapped__') else None # Fallback: manually list all expected feature names names = [] # MFCC (80 features) names.extend(['mfcc_mean', 'mfcc_var']) for i in range(1, 14): names.extend([f'mfcc_{i}_mean', f'mfcc_{i}_var']) names.extend([f'delta_mfcc_{i}_mean', f'delta_mfcc_{i}_var']) names.extend([f'delta2_mfcc_{i}_mean', f'delta2_mfcc_{i}_var']) # Spectral (24 features) names.extend(['spec_cent_mean', 'spec_cent_var']) names.extend(['spec_bw_mean', 'spec_bw_var']) names.extend(['spec_flat_mean', 'spec_flat_var']) names.extend(['spec_roll_mean', 'spec_roll_var']) for i in range(7): names.append(f'spec_contrast_{i}_mean') for i in range(6): names.append(f'tonnetz_{i}_mean') # Energy & Rhythm (6 features) names.extend(['rms_mean', 'rms_var', 'zcr_mean', 'zcr_var', 'chroma_mean', 'chroma_var']) # Pitch (4 features) names.extend(['pitch_mean', 'pitch_std', 'pitch_range', 'pitch_cv']) # Voice Quality (10 features) names.extend(['jitter_local', 'jitter_rap', 'jitter_ppq5']) names.extend(['shimmer_local', 'shimmer_apq3', 'shimmer_apq5']) names.extend(['hnr_mean']) names.extend(['formant_f1_mean', 'formant_f2_mean', 'formant_f3_mean']) # Spectral Stats (3 features) names.extend(['spec_skewness', 'spec_kurtosis', 'spec_entropy']) # Silence (3 features) names.extend(['silence_ratio', 'pause_count', 'pause_mean_duration']) # Modulation (2 features) names.extend(['mod_index_4hz', 'mod_peak_freq']) return names # ============================================================ # Batch Extraction (from master dataset) # ============================================================ def main(): """ Extract v2 features from all samples in master_dataset.csv """ master_csv = os.path.join(DATA_DIR, 'master_dataset.csv') if not os.path.exists(master_csv): print("Master dataset not found. Run preprocessing first.") return df = pd.read_csv(master_csv) feature_list = [] failed = [] print(f"Extracting v2 DSP Features from {len(df)} samples...") print(f" Parselmouth available: {HAS_PARSELMOUTH}") for index, row in tqdm(df.iterrows(), total=len(df)): file_path = row['path'] features = extract_all_features_v2(file_path) if features: features['filename'] = row['filename'] features['label'] = row['label'] feature_list.append(features) else: failed.append(file_path) # Save feature_df = pd.DataFrame(feature_list) output_dir = os.path.join(DATA_DIR, 'features') os.makedirs(output_dir, exist_ok=True) output_path = os.path.join(output_dir, 'dsp_features_v2.csv') feature_df.to_csv(output_path, index=False) print(f"\nv2 Feature Extraction Complete!") print(f" Saved to: {output_path}") print(f" Total features per sample: {len(feature_df.columns) - 2}") # minus filename, label print(f" Successful: {len(feature_list)}") print(f" Failed: {len(failed)}") if failed: print(f"\nFailed files:") for f in failed[:10]: print(f" - {f}") if len(failed) > 10: print(f" ... and {len(failed) - 10} more") if __name__ == "__main__": main()