arabic-native-classifier / scripts /extract_embeddings.py
Sarthak-gupta-cpp
changes
4dc7304
Raw History Blame Contribute Delete
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})."
)