from collections import Counter from pathlib import Path import warnings import librosa import numpy as np SAMPLE_RATE = 22050 LABEL_PD = "PD" LABEL_TPD = "TPD" VALID_LABELS = {LABEL_PD, LABEL_TPD} N_MFCC = 13 MIN_DURATION_SECONDS = 0.30 MIN_RMS_FOR_USABLE_AUDIO = 0.001 def get_label_from_filename(file_path): """ Mengambil label dari nama file atau nama folder. Urutan pengecekan penting: - cek "_tpd" lebih dulu - baru cek "_pd" """ path = Path(file_path) filename = path.stem.lower() parent = path.parent.name.lower() if "_tpd" in filename or parent in {"tpd", "not_confident", "tidak_percaya_diri"}: return LABEL_TPD if "_pd" in filename or parent in {"pd", "confident", "percaya_diri"}: return LABEL_PD return None def validate_label(label, file_path): if label not in VALID_LABELS: raise ValueError(f"Label tidak valid pada {Path(file_path).name}: {label}") def load_and_preprocess_audio(file_path, sample_rate=SAMPLE_RATE): """ Membaca audio dengan preprocessing konsisten untuk training dan prediksi: mono, sample rate 22050 Hz, trim silence, dan normalisasi volume. """ y, sr = librosa.load(file_path, sr=sample_rate, mono=True) if y.size == 0: raise ValueError(f"Audio kosong: {file_path}") y, _ = librosa.effects.trim(y, top_db=30) if y.size == 0: raise ValueError(f"Audio hanya berisi silence: {file_path}") duration = librosa.get_duration(y=y, sr=sr) if duration < MIN_DURATION_SECONDS: raise ValueError( f"Audio terlalu pendek: {duration:.2f} detik. Minimal {MIN_DURATION_SECONDS:.2f} detik." ) rms_value = float(np.sqrt(np.mean(y**2))) if rms_value < MIN_RMS_FOR_USABLE_AUDIO: raise ValueError( f"Audio terlalu pelan/silent. RMS={rms_value:.5f}, " f"minimal {MIN_RMS_FOR_USABLE_AUDIO:.5f}." ) max_amplitude = np.max(np.abs(y)) if max_amplitude > 0: y = y / max_amplitude return y.astype(np.float32), sr def mean_std(feature_matrix): """ Mengubah fitur frame-based menjadi statistik tetap. Output selalu 1 dimensi dan stabil untuk SVM. """ feature_matrix = np.atleast_2d(feature_matrix) return np.concatenate( [ np.mean(feature_matrix, axis=1), np.std(feature_matrix, axis=1), ] ) def extract_pitch_features(y, sr): """ Mengambil ringkasan fundamental frequency (pitch) dengan pyin. Jika pitch tidak terdeteksi, nilai pitch dibuat 0 agar fitur tetap konsisten. """ f0, _, _ = librosa.pyin( y, fmin=librosa.note_to_hz("C2"), fmax=librosa.note_to_hz("C7"), sr=sr, ) voiced_f0 = f0[~np.isnan(f0)] if voiced_f0.size == 0: return np.array([0.0, 0.0, 0.0], dtype=np.float32) voiced_ratio = voiced_f0.size / f0.size return np.array( [ np.mean(voiced_f0), np.std(voiced_f0), voiced_ratio, ], dtype=np.float32, ) def extract_features(file_path, sample_rate=SAMPLE_RATE): """ Ekstraksi fitur suara yang sama untuk training dan prediksi: - MFCC mean dan std - RMS Energy mean dan std - Zero Crossing Rate mean dan std - Spectral Centroid mean dan std - Spectral Bandwidth mean dan std - Spectral Rolloff mean dan std - Pitch/fundamental frequency - Durasi suara aktif """ y, sr = load_and_preprocess_audio(file_path, sample_rate=sample_rate) mfcc = librosa.feature.mfcc(y=y, sr=sr, n_mfcc=N_MFCC) rms = librosa.feature.rms(y=y) zcr = librosa.feature.zero_crossing_rate(y) spectral_centroid = librosa.feature.spectral_centroid(y=y, sr=sr) spectral_bandwidth = librosa.feature.spectral_bandwidth(y=y, sr=sr) spectral_rolloff = librosa.feature.spectral_rolloff(y=y, sr=sr) pitch_features = extract_pitch_features(y, sr) active_duration = np.array([librosa.get_duration(y=y, sr=sr)], dtype=np.float32) feature_vector = np.concatenate( [ mean_std(mfcc), mean_std(rms), mean_std(zcr), mean_std(spectral_centroid), mean_std(spectral_bandwidth), mean_std(spectral_rolloff), pitch_features, active_duration, ] ) if feature_vector.ndim != 1: raise ValueError("Fitur audio harus 1 dimensi.") if not np.all(np.isfinite(feature_vector)): raise ValueError("Fitur audio mengandung NaN atau infinity.") return feature_vector.astype(np.float32) def load_dataset(data_dir): """ Membaca semua file .wav pada folder data. File tanpa label valid atau file rusak dilewati dengan peringatan. """ data_path = Path(data_dir) audio_files = sorted(data_path.rglob("*.wav")) if not audio_files: raise FileNotFoundError(f"Tidak ada file .wav di folder: {data_path}") features = [] labels = [] used_files = [] for audio_file in audio_files: label = get_label_from_filename(audio_file) if label is None: warnings.warn( f"File dilewati karena nama/folder tidak mengandung label PD atau TPD: " f"{audio_file.name}" ) continue try: validate_label(label, audio_file) features.append(extract_features(audio_file)) labels.append(label) used_files.append(audio_file) except Exception as error: warnings.warn(f"File dilewati karena gagal diproses: {audio_file.name} ({error})") if not features: raise ValueError("Tidak ada file audio valid yang berhasil diproses.") label_counts = Counter(labels) print("\n=== Distribusi Label Dataset ===") print(f"PD : {label_counts.get(LABEL_PD, 0)}") print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") invalid_labels = set(labels) - VALID_LABELS if invalid_labels: raise ValueError(f"Ditemukan label tidak valid: {sorted(invalid_labels)}") return np.array(features), np.array(labels), used_files def check_dataset_quality(data_dir): """ Mengecek kualitas dataset: - jumlah data PD dan TPD - durasi setiap audio - audio terlalu pendek - audio terlalu pelan/silent - file rusak - rekomendasi file yang perlu direkam ulang """ data_path = Path(data_dir) audio_files = sorted(data_path.rglob("*.wav")) label_counts = Counter() problems = [] print("\n=== Cek Kualitas Dataset ===") for audio_file in audio_files: label = get_label_from_filename(audio_file) if label is None: problems.append((audio_file.name, "Label tidak ditemukan")) continue label_counts[label] += 1 try: y_raw, sr = librosa.load(audio_file, sr=SAMPLE_RATE, mono=True) duration_raw = librosa.get_duration(y=y_raw, sr=sr) rms_raw = float(np.sqrt(np.mean(y_raw**2))) if y_raw.size else 0.0 issue_notes = [] if duration_raw < MIN_DURATION_SECONDS: issue_notes.append(f"terlalu pendek ({duration_raw:.2f} detik)") if rms_raw < MIN_RMS_FOR_USABLE_AUDIO: issue_notes.append(f"terlalu pelan/silent (RMS={rms_raw:.5f})") print( f"{audio_file.name} | label={label} | durasi={duration_raw:.2f}s | " f"rms={rms_raw:.5f}" ) if issue_notes: problems.append((audio_file.name, ", ".join(issue_notes))) except Exception as error: problems.append((audio_file.name, f"file rusak/gagal dibaca ({error})")) print("\nJumlah data:") print(f"PD : {label_counts.get(LABEL_PD, 0)}") print(f"TPD: {label_counts.get(LABEL_TPD, 0)}") print("\nRekomendasi rekam ulang/perbaikan:") if not problems: print("Tidak ada masalah kualitas audio yang jelas.") else: for filename, reason in problems: print(f"- {filename}: {reason}") return problems