| import librosa |
| import numpy as np |
| import torch |
|
|
| try: |
| import parselmouth |
| from parselmouth.praat import call |
| except ModuleNotFoundError: |
| 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 |
| 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) |
|
|