aud-relapse-risk-detector / preprocess.py
Jimisaq's picture
Add MADA inference pipeline
c2c79b5
Raw
History Blame Contribute Delete
7.62 kB
import librosa
import numpy as np
import torch
try:
import parselmouth
from parselmouth.praat import call
except ModuleNotFoundError: # pragma: no cover - optional at runtime
parselmouth = None
call = None
TARGET_SAMPLE_RATE = 16000
CLIP_DURATION_SECONDS = 4.0
CLIP_NUM_SAMPLES = int(TARGET_SAMPLE_RATE * CLIP_DURATION_SECONDS)
FEATURE_DIM = 418
NUM_MFCC = 40
NUM_MELS = 128
def load_and_standardize_audio(
audio_path: str,
sample_rate: int = TARGET_SAMPLE_RATE,
required_num_samples: int = CLIP_NUM_SAMPLES,
) -> np.ndarray:
waveform, _ = librosa.load(audio_path, sr=sample_rate, mono=True)
if len(waveform) < required_num_samples:
waveform = np.pad(waveform, (0, required_num_samples - len(waveform)), mode="constant")
else:
waveform = waveform[:required_num_samples]
peak = float(np.max(np.abs(waveform))) if len(waveform) else 0.0
if peak > 0:
waveform = waveform / peak
return waveform.astype(np.float32)
def load_and_standardise(
audio_path: str,
sr: int = TARGET_SAMPLE_RATE,
target_len: int = CLIP_NUM_SAMPLES,
) -> np.ndarray:
return load_and_standardize_audio(
audio_path=audio_path,
sample_rate=sr,
required_num_samples=target_len,
)
def augment_waveform(
waveform: np.ndarray,
sample_rate: int = TARGET_SAMPLE_RATE,
augment: bool = True,
) -> np.ndarray:
del sample_rate # kept for notebook-signature parity
if not augment:
return waveform
gain = 10 ** (np.random.uniform(-0.3, 0.3) / 20)
augmented = waveform * gain
snr_db = np.random.uniform(25, 40)
signal_power = np.mean(augmented ** 2) + 1e-10
noise_power = signal_power / (10 ** (snr_db / 10))
augmented = augmented + np.random.randn(len(augmented)).astype(np.float32) * np.sqrt(noise_power)
peak = np.max(np.abs(augmented))
if peak > 0:
augmented = augmented / peak
return augmented.astype(np.float32)
def extract_features_418_from_waveform(
waveform: np.ndarray,
sample_rate: int = TARGET_SAMPLE_RATE,
augment: bool = False,
) -> np.ndarray:
"""
Notebook-faithful 418-dim acoustic feature vector.
The F0 group uses delta-F0 and z-score normalized F0 instead of raw Hz values
to reduce pitch leakage into the tonal-language deployment domain.
"""
if augment:
waveform = augment_waveform(waveform, sample_rate=sample_rate, augment=True)
features: list[float] = []
n_fft = min(2048, len(waveform))
hop_length = int(sample_rate * 0.010)
mfcc = librosa.feature.mfcc(
y=waveform,
sr=sample_rate,
n_mfcc=NUM_MFCC,
n_fft=int(sample_rate * 0.025),
hop_length=hop_length,
)
mfcc_delta = librosa.feature.delta(mfcc)
mfcc_delta2 = librosa.feature.delta(mfcc, order=2)
for matrix in (mfcc, mfcc_delta, mfcc_delta2):
features.extend(np.mean(matrix, axis=1).tolist())
features.extend(np.std(matrix, axis=1).tolist())
try:
sound = parselmouth.Sound(waveform, sampling_frequency=float(sample_rate))
pitch = sound.to_pitch()
f0 = pitch.selected_array["frequency"]
voiced_f0 = f0[f0 > 0]
if len(voiced_f0) > 5:
f0_zscore = (voiced_f0 - np.mean(voiced_f0)) / (np.std(voiced_f0) + 1e-8)
delta_f0 = np.diff(voiced_f0)
pitch_features = [
float(np.mean(f0_zscore)),
float(np.std(f0_zscore)),
float(np.mean(np.abs(delta_f0))),
float(np.std(delta_f0)),
float(np.sum(delta_f0 > 0)) / max(len(delta_f0), 1),
float(np.polyfit(np.linspace(0, 1, len(voiced_f0)), f0_zscore, 1)[0]),
]
else:
pitch_features = [0.0] * 6
except Exception:
pitch_features = [0.0] * 6
features.extend(pitch_features)
rms = librosa.feature.rms(y=waveform)[0]
features.extend([float(np.mean(rms)), float(np.std(rms))])
zero_crossing_rate = librosa.feature.zero_crossing_rate(y=waveform)[0]
features.extend([float(np.mean(zero_crossing_rate)), float(np.std(zero_crossing_rate))])
spectral_centroid = librosa.feature.spectral_centroid(
y=waveform,
sr=sample_rate,
n_fft=n_fft,
hop_length=hop_length,
)[0]
spectral_bandwidth = librosa.feature.spectral_bandwidth(
y=waveform,
sr=sample_rate,
n_fft=n_fft,
hop_length=hop_length,
)[0]
spectral_rolloff = librosa.feature.spectral_rolloff(
y=waveform,
sr=sample_rate,
n_fft=n_fft,
hop_length=hop_length,
roll_percent=0.85,
)[0]
onset_strength = librosa.onset.onset_strength(
y=waveform,
sr=sample_rate,
hop_length=hop_length,
)
spectral_flatness = librosa.feature.spectral_flatness(
y=waveform,
n_fft=n_fft,
hop_length=hop_length,
)[0]
for descriptor in (
spectral_centroid,
spectral_bandwidth,
spectral_rolloff,
onset_strength,
spectral_flatness,
):
features.extend([float(np.mean(descriptor)), float(np.std(descriptor))])
mel_spectrogram = librosa.feature.melspectrogram(
y=waveform,
sr=sample_rate,
n_mels=NUM_MELS,
n_fft=n_fft,
hop_length=hop_length,
)
features.extend(np.mean(mel_spectrogram, axis=1).tolist())
try:
sound = parselmouth.Sound(waveform, sampling_frequency=float(sample_rate))
harmonicity = call(sound, "To Harmonicity (cc)", 0.01, 75, 0.1, 1.0)
hnr = call(harmonicity, "Get mean", 0, 0)
point_process = call(sound, "To PointProcess (periodic, cc)", 75, 500)
voice_quality_features = [
call(point_process, "Get jitter (local)", 0, 0, 0.0001, 0.02, 1.3),
call(point_process, "Get jitter (local, absolute)", 0, 0, 0.0001, 0.02, 1.3),
call(point_process, "Get jitter (rap)", 0, 0, 0.0001, 0.02, 1.3),
call([sound, point_process], "Get shimmer (local)", 0, 0, 0.0001, 0.02, 1.3, 1.6),
call([sound, point_process], "Get shimmer (apq3)", 0, 0, 0.0001, 0.02, 1.3, 1.6),
]
voice_quality_features = [hnr, *voice_quality_features]
voice_quality_features = [
0.0 if (value is None or not np.isfinite(value)) else float(value)
for value in voice_quality_features
]
except Exception:
voice_quality_features = [0.0] * 6
features.extend(voice_quality_features)
chroma_cens = librosa.feature.chroma_cens(y=waveform, sr=sample_rate, n_chroma=12)
features.extend(np.mean(chroma_cens, axis=1).tolist())
features.extend(np.std(chroma_cens, axis=1).tolist())
vector = np.asarray(features, dtype=np.float32)
if vector.shape[0] != FEATURE_DIM:
raise ValueError(f"Feature dim mismatch: expected {FEATURE_DIM}, got {vector.shape[0]}.")
return np.nan_to_num(vector, nan=0.0, posinf=0.0, neginf=0.0)
def extract_features_418(audio_path: str, augment: bool = False) -> np.ndarray:
waveform = load_and_standardize_audio(audio_path)
return extract_features_418_from_waveform(
waveform=waveform,
sample_rate=TARGET_SAMPLE_RATE,
augment=augment,
)
def preprocess_audio_file_for_mada(
audio_path: str,
scaler=None,
) -> torch.Tensor:
features = extract_features_418(audio_path).reshape(1, -1)
if scaler is not None:
features = scaler.transform(features)
return torch.tensor(features, dtype=torch.float32)