Spaces:
Sleeping
Sleeping
Download scripts/extract_embeddings.py from neev2211/arabic-native-classifier: direct link, hf CLI and curl.
- Browser
- Download file 8.6 kB
-
https://huggingface.co/spaces/neev2211/arabic-native-classifier/resolve/main/scripts/extract_embeddings.py
- Command line
-
hf download hf://spaces/neev2211/arabic-native-classifier/scripts/extract_embeddings.py
-
curl -L -o extract_embeddings.py https://huggingface.co/spaces/neev2211/arabic-native-classifier/resolve/main/scripts/extract_embeddings.py
8.6 kB
| """ | |
| Extract embeddings for Arabic Native vs Non-Native classifier. | |
| Emphasizes PROSODIC features: pitch, rhythm, timing, fluency — key for Native vs Non-Native. | |
| Uses XLS-R 53 EARLY-TO-MID layers (4,6,8,10): prosody/speaker-rich, less lexical than final layer. | |
| """ | |
| import pandas as pd | |
| import numpy as np | |
| import torch | |
| import librosa | |
| import os | |
| from transformers import Wav2Vec2Model, AutoFeatureExtractor | |
| from tqdm import tqdm | |
| from scipy.signal import butter, lfilter | |
| # XLS-R 53 (multilingual, includes Arabic) | |
| # model_name = "facebook/wav2vec2-large-xlsr-53" | |
| model_name = "facebook/wav2vec2-lv-60-espeak-cv-ft" | |
| # Prosody-focused layers: early-mid (4,6,8,10) encode pitch, rhythm, timing, speaker | |
| # Final layers encode lexicon; we want prosodic cues for nativeness | |
| PROSODY_LAYERS = (4, 6, 8, 10) | |
| # Lazy-loaded so "from extract_embeddings import get_mfcc_from_audio, ..." doesn't load the big model (avoids 2x GPU OOM in compare_models_on_test) | |
| _feature_extractor = None | |
| _model = None | |
| device = torch.device("cuda" if torch.cuda.is_available() else "cpu") | |
| def apply_whisper_filter(audio_array, sr=16000, cutoff_hz=700): | |
| """ | |
| Applies a High-Pass Butterworth filter to strip fundamental pitch | |
| and isolate high-frequency phonetic articulation. | |
| """ | |
| nyq = 0.5 * sr | |
| normal_cutoff = cutoff_hz / nyq | |
| b, a = butter(5, normal_cutoff, btype="high", analog=False) | |
| whispered_audio = lfilter(b, a, audio_array) | |
| return whispered_audio.astype(np.float32) | |
| def _get_wav2vec(): | |
| global _feature_extractor, _model | |
| if _model is None: | |
| _feature_extractor = AutoFeatureExtractor.from_pretrained(model_name) | |
| _model = Wav2Vec2Model.from_pretrained(model_name) | |
| _model.eval() | |
| _model.to(device) | |
| return _feature_extractor, _model | |
| # Embedding dims: paper uses mean + std + F0 → 1024+1024+3 = 2051; optional MFCC → 2090 | |
| WAV2VEC_DIM = 1024 | |
| PITCH_DIM = 3 # mean, std, max of F0 (voiced) | |
| PROSODY_EMBED_DIM = WAV2VEC_DIM * 2 + PITCH_DIM # 2051 (mean + std + pitch) | |
| MFCC_DIM = 39 # 13 + 13 + 13 | |
| EMBED_DIM_WITH_MFCC = PROSODY_EMBED_DIM + MFCC_DIM # 2090 | |
| def get_pitch_stats(speech, sr=16000, fmin=50, fmax=500): | |
| """F0 (pitch) stats for prosody: mean, std, max over voiced frames (paper: explicit pitch helps dialect/nativity).""" | |
| try: | |
| f0 = librosa.yin( | |
| speech.astype(np.float64), fmin=float(fmin), fmax=float(fmax), sr=sr | |
| ) | |
| voiced = (f0 > 0) & np.isfinite(f0) | |
| if np.any(voiced): | |
| f0_v = f0[voiced] | |
| return np.array( | |
| [float(np.mean(f0_v)), float(np.std(f0_v)), float(np.max(f0_v))], | |
| dtype=np.float32, | |
| ) | |
| except Exception: | |
| pass | |
| return np.zeros(PITCH_DIM, dtype=np.float32) | |
| def get_mfcc_from_audio(speech, sr=16000, n_mfcc=13): | |
| """Classical 39-d feature: MFCC mean + delta + delta2 (paper: MFCC >> LPCC/PLP for accent).""" | |
| mfcc = librosa.feature.mfcc(y=speech, sr=sr, n_mfcc=n_mfcc) | |
| mfcc_mean = mfcc.mean(axis=1) | |
| mfcc_delta = librosa.feature.delta(mfcc).mean(axis=1) | |
| mfcc_delta2 = librosa.feature.delta(mfcc, order=2).mean(axis=1) | |
| return np.concatenate([mfcc_mean, mfcc_delta, mfcc_delta2]).astype(np.float32) | |
| def get_embeddings(audio_path, l2_norm=True, return_audio=False): | |
| """Prosody embedding: wav2vec mean + std (temporal variance) + F0 stats (pitch) → 2051-d. | |
| Paper: explicit pitch and time-derivatives (std) help dialect/nativity. Optional return_audio for MFCC.""" | |
| feature_extractor, model = _get_wav2vec() | |
| try: | |
| speech, sr = librosa.load(audio_path, sr=16000) | |
| # 1. Calculate REAL pitch from the unfiltered audio | |
| pitch = get_pitch_stats(speech, sr=sr) | |
| # 2. Apply WHISPER FILTER to blind the model to speaker identity | |
| # whispered_speech = apply_whisper_filter(speech, sr=sr) | |
| whispered_speech = speech | |
| # 3. Pass the WHISPERED speech to Wav2Vec2 | |
| input_values = feature_extractor( | |
| whispered_speech, return_tensors="pt", sampling_rate=16000 | |
| ).input_values.to(device) | |
| with torch.no_grad(): | |
| outputs = model(input_values, output_hidden_states=True) | |
| h = outputs.hidden_states | |
| num_layers = len(h) - 1 | |
| layer_idx = [min(max(l, 1), num_layers) for l in PROSODY_LAYERS] | |
| embs = [h[i] for i in layer_idx] | |
| # (1, T, 1024) after stack+mean over layers; keep time for mean+std | |
| time_seq = torch.stack(embs, dim=0).mean(dim=0).squeeze(0) # (T, 1024) | |
| emb_mean = time_seq.mean(dim=0).cpu().numpy().astype(np.float32) | |
| emb_std = time_seq.std(dim=0).cpu().numpy().astype(np.float32) | |
| np.nan_to_num(emb_std, copy=False, nan=0.0, posinf=0.0, neginf=0.0) | |
| emb = np.concatenate([emb_mean, emb_std, pitch]) | |
| if l2_norm: | |
| n = np.linalg.norm(emb) | |
| if n > 1e-8: | |
| emb = emb / n | |
| if return_audio: | |
| # Return the WHISPERED speech so the MFCC extractor also uses the filtered version | |
| return emb, whispered_speech, sr | |
| return emb | |
| except Exception as e: | |
| print(f"Error on {audio_path}: {e}") | |
| return None | |
| # CLI: optional (manifest_in, embedding_dir, manifest_out) for e.g. Iqra data | |
| if __name__ == "__main__": | |
| import argparse | |
| ap = argparse.ArgumentParser( | |
| description="Extract XLS-R prosody embeddings from chunk manifest." | |
| ) | |
| ap.add_argument( | |
| "--manifest", | |
| default="manifests/processed_manifest.csv", | |
| help="Input manifest CSV (chunk_path, original_id, label, language)", | |
| ) | |
| ap.add_argument( | |
| "--output-dir", default="embeddings", help="Folder for .npy embeddings" | |
| ) | |
| ap.add_argument( | |
| "--output-manifest", | |
| default=None, | |
| help="Output manifest CSV (default: final_training_manifest.csv or derived from --manifest)", | |
| ) | |
| ap.add_argument( | |
| "--mfcc", | |
| action="store_true", | |
| help="Append 39-d MFCC → 2090-d (default prosody is 2051-d: mean+std+pitch)", | |
| ) | |
| args = ap.parse_args() | |
| manifest_path = args.manifest | |
| output_folder = args.output_dir | |
| use_mfcc = args.mfcc | |
| if args.output_manifest: | |
| output_manifest_path = args.output_manifest | |
| else: | |
| # Main pipeline: processed_manifest -> final_training_manifest; else e.g. iqra_processed_manifest -> iqra_embeddings_manifest | |
| if manifest_path.strip() == "manifests/processed_manifest.csv": | |
| output_manifest_path = "manifests/final_training_manifest.csv" | |
| else: | |
| d = os.path.dirname(manifest_path) | |
| base = ( | |
| os.path.basename(manifest_path) | |
| .replace(".csv", "") | |
| .replace("_processed", "_embeddings") | |
| ) | |
| output_manifest_path = os.path.join(d, base + ".csv") | |
| os.makedirs(output_folder, exist_ok=True) | |
| df = pd.read_csv(manifest_path) | |
| embedding_paths = [] | |
| feat_desc = f"prosody mean+std+pitch ({PROSODY_EMBED_DIM}d)" + ( | |
| f" + MFCC({MFCC_DIM}d)" if use_mfcc else "" | |
| ) | |
| print(f"Extracting embeddings for {len(df)} chunks ({feat_desc}, L2-normalized)...") | |
| for idx, row in tqdm(df.iterrows(), total=len(df)): | |
| if use_mfcc: | |
| out = get_embeddings(row["chunk_path"], l2_norm=False, return_audio=True) | |
| if out is None: | |
| embedding_paths.append(None) | |
| continue | |
| emb, speech, sr = out | |
| mfcc_39 = get_mfcc_from_audio(speech, sr=sr) | |
| emb = np.concatenate([emb, mfcc_39]).astype(np.float32) | |
| n = np.linalg.norm(emb) | |
| if n > 1e-8: | |
| emb = emb / n | |
| else: | |
| emb = get_embeddings(row["chunk_path"], l2_norm=True) | |
| if emb is not None: | |
| emb_filename = os.path.basename(row["chunk_path"]).replace(".wav", ".npy") | |
| emb_path = os.path.join(output_folder, emb_filename) | |
| np.save(emb_path, emb) | |
| embedding_paths.append(emb_path) | |
| else: | |
| embedding_paths.append(None) | |
| df["embedding_path"] = embedding_paths | |
| df.dropna(subset=["embedding_path"], inplace=True) | |
| df.to_csv(output_manifest_path, index=False) | |
| embed_dim = EMBED_DIM_WITH_MFCC if use_mfcc else PROSODY_EMBED_DIM | |
| dim_file = os.path.join(os.path.dirname(output_manifest_path), "embedding_dim.txt") | |
| with open(dim_file, "w") as f: | |
| f.write(str(embed_dim)) | |
| print( | |
| f"Done! Embeddings in {output_folder}, manifest at {output_manifest_path}, dim={embed_dim} (saved to {dim_file})." | |
| ) | |