Spaces:
Running on Zero
Running on Zero
| """ | |
| RAVDESS metadata preparation for audio emotion classification. | |
| This script scans the RAVDESS speech audio files, parses emotion labels from | |
| filenames, maps them into the project emotion schema, and creates a clean | |
| metadata CSV for training/evaluation. | |
| Expected RAVDESS filename format: | |
| 03-01-05-01-01-01-01.wav | |
| Filename parts: | |
| modality-vocal_channel-emotion-intensity-statement-repetition-actor | |
| Emotion codes: | |
| 01 = neutral | |
| 02 = calm | |
| 03 = happy | |
| 04 = sad | |
| 05 = angry | |
| 06 = fearful | |
| 07 = disgust | |
| 08 = surprised | |
| Project mapping: | |
| neutral -> neutral | |
| calm -> neutral | |
| happy -> happy | |
| sad -> sadness | |
| angry -> anger | |
| fearful -> fear | |
| disgust -> disgust | |
| surprised is skipped because the project has no surprise class. | |
| Run from ml-services: | |
| python -m src.data.ravdess_dataset | |
| """ | |
| import json | |
| import random | |
| from dataclasses import asdict, dataclass | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| import pandas as pd | |
| PROJECT_ROOT = Path(__file__).resolve().parents[3] | |
| ML_SERVICES_ROOT = PROJECT_ROOT / "ml-services" | |
| RAVDESS_RAW_DIR = ML_SERVICES_ROOT / "data" / "raw" / "ravdess" | |
| PROCESSED_DIR = ML_SERVICES_ROOT / "data" / "processed" | |
| RAVDESS_METADATA_PATH = PROCESSED_DIR / "ravdess_metadata.csv" | |
| RAVDESS_SUMMARY_PATH = PROCESSED_DIR / "ravdess_summary.json" | |
| RAVDESS_EMOTION_CODE_MAP = { | |
| "01": "neutral", | |
| "02": "calm", | |
| "03": "happy", | |
| "04": "sad", | |
| "05": "angry", | |
| "06": "fearful", | |
| "07": "disgust", | |
| "08": "surprised", | |
| } | |
| RAVDESS_TO_PROJECT_EMOTION = { | |
| "neutral": "neutral", | |
| "calm": "neutral", | |
| "happy": "happy", | |
| "sad": "sadness", | |
| "angry": "anger", | |
| "fearful": "fear", | |
| "disgust": "disgust", | |
| } | |
| NEGATIVE_EMOTIONS = {"anger", "disgust", "fear", "sadness"} | |
| class RavdessMetadataRecord: | |
| """ | |
| One RAVDESS audio metadata row. | |
| """ | |
| file_path: str | |
| filename: str | |
| dataset: str | |
| actor_id: int | |
| modality_code: str | |
| vocal_channel_code: str | |
| emotion_code: str | |
| ravdess_emotion_label: str | |
| emotion_label: str | |
| sentiment_label: str | |
| intensity_code: str | |
| statement_code: str | |
| repetition_code: str | |
| is_negative: bool | |
| split: str | |
| def infer_sentiment_label(emotion_label: str) -> str: | |
| """ | |
| Map project emotion label to business sentiment. | |
| """ | |
| if emotion_label in NEGATIVE_EMOTIONS: | |
| return "Negative" | |
| if emotion_label == "happy": | |
| return "Positive" | |
| return "Neutral" | |
| def find_ravdess_audio_files(raw_dir: Path = RAVDESS_RAW_DIR) -> List[Path]: | |
| """ | |
| Find all RAVDESS WAV files recursively. | |
| """ | |
| if not raw_dir.exists(): | |
| raise FileNotFoundError( | |
| f"RAVDESS raw directory not found: {raw_dir}\n" | |
| "Expected the dataset under data/raw/ravdess/" | |
| ) | |
| audio_files = sorted(raw_dir.rglob("*.wav")) | |
| if not audio_files: | |
| raise FileNotFoundError( | |
| f"No WAV files found under: {raw_dir}\n" | |
| "Make sure RAVDESS was extracted correctly." | |
| ) | |
| return audio_files | |
| def parse_ravdess_filename(file_path: Path) -> Optional[Dict]: | |
| """ | |
| Parse one RAVDESS filename. | |
| Returns None for unsupported labels, such as surprised. | |
| """ | |
| filename = file_path.name | |
| stem = file_path.stem | |
| parts = stem.split("-") | |
| if len(parts) != 7: | |
| raise ValueError(f"Invalid RAVDESS filename format: {filename}") | |
| ( | |
| modality_code, | |
| vocal_channel_code, | |
| emotion_code, | |
| intensity_code, | |
| statement_code, | |
| repetition_code, | |
| actor_code, | |
| ) = parts | |
| if emotion_code not in RAVDESS_EMOTION_CODE_MAP: | |
| raise ValueError(f"Unknown RAVDESS emotion code {emotion_code} in {filename}") | |
| ravdess_emotion = RAVDESS_EMOTION_CODE_MAP[emotion_code] | |
| # Skip surprised because our current project schema does not include surprise. | |
| if ravdess_emotion == "surprised": | |
| return None | |
| if ravdess_emotion not in RAVDESS_TO_PROJECT_EMOTION: | |
| raise ValueError(f"No project mapping for RAVDESS emotion: {ravdess_emotion}") | |
| project_emotion = RAVDESS_TO_PROJECT_EMOTION[ravdess_emotion] | |
| actor_id = int(actor_code) | |
| return { | |
| "filename": filename, | |
| "actor_id": actor_id, | |
| "modality_code": modality_code, | |
| "vocal_channel_code": vocal_channel_code, | |
| "emotion_code": emotion_code, | |
| "ravdess_emotion_label": ravdess_emotion, | |
| "emotion_label": project_emotion, | |
| "sentiment_label": infer_sentiment_label(project_emotion), | |
| "intensity_code": intensity_code, | |
| "statement_code": statement_code, | |
| "repetition_code": repetition_code, | |
| "is_negative": project_emotion in NEGATIVE_EMOTIONS, | |
| } | |
| def create_actor_split( | |
| actor_ids: List[int], | |
| train_ratio: float = 0.70, | |
| validation_ratio: float = 0.15, | |
| seed: int = 42, | |
| ) -> Dict[int, str]: | |
| """ | |
| Create speaker-aware train/validation/test split. | |
| This keeps each actor in only one split. | |
| """ | |
| unique_actors = sorted(set(actor_ids)) | |
| random_generator = random.Random(seed) | |
| random_generator.shuffle(unique_actors) | |
| total_actors = len(unique_actors) | |
| train_count = int(total_actors * train_ratio) | |
| validation_count = int(total_actors * validation_ratio) | |
| train_actors = set(unique_actors[:train_count]) | |
| validation_actors = set(unique_actors[train_count : train_count + validation_count]) | |
| test_actors = set(unique_actors[train_count + validation_count :]) | |
| actor_to_split = {} | |
| for actor_id in train_actors: | |
| actor_to_split[actor_id] = "train" | |
| for actor_id in validation_actors: | |
| actor_to_split[actor_id] = "validation" | |
| for actor_id in test_actors: | |
| actor_to_split[actor_id] = "test" | |
| return actor_to_split | |
| def build_ravdess_metadata() -> pd.DataFrame: | |
| """ | |
| Build RAVDESS metadata dataframe. | |
| """ | |
| audio_files = find_ravdess_audio_files() | |
| parsed_records = [] | |
| skipped_files = [] | |
| for file_path in audio_files: | |
| parsed = parse_ravdess_filename(file_path) | |
| if parsed is None: | |
| skipped_files.append(file_path.name) | |
| continue | |
| parsed["file_path"] = str(file_path) | |
| parsed["dataset"] = "RAVDESS" | |
| parsed_records.append(parsed) | |
| if not parsed_records: | |
| raise ValueError("No usable RAVDESS records were parsed.") | |
| actor_ids = [record["actor_id"] for record in parsed_records] | |
| actor_to_split = create_actor_split(actor_ids) | |
| records: List[RavdessMetadataRecord] = [] | |
| for record in parsed_records: | |
| actor_id = record["actor_id"] | |
| metadata_record = RavdessMetadataRecord( | |
| file_path=record["file_path"], | |
| filename=record["filename"], | |
| dataset=record["dataset"], | |
| actor_id=actor_id, | |
| modality_code=record["modality_code"], | |
| vocal_channel_code=record["vocal_channel_code"], | |
| emotion_code=record["emotion_code"], | |
| ravdess_emotion_label=record["ravdess_emotion_label"], | |
| emotion_label=record["emotion_label"], | |
| sentiment_label=record["sentiment_label"], | |
| intensity_code=record["intensity_code"], | |
| statement_code=record["statement_code"], | |
| repetition_code=record["repetition_code"], | |
| is_negative=record["is_negative"], | |
| split=actor_to_split[actor_id], | |
| ) | |
| records.append(metadata_record) | |
| dataframe = pd.DataFrame([asdict(record) for record in records]) | |
| dataframe = dataframe.sort_values( | |
| by=["split", "actor_id", "emotion_label", "filename"] | |
| ).reset_index(drop=True) | |
| return dataframe | |
| def create_summary(dataframe: pd.DataFrame) -> Dict: | |
| """ | |
| Create RAVDESS metadata summary. | |
| """ | |
| summary = { | |
| "dataset": "RAVDESS", | |
| "total_records": int(len(dataframe)), | |
| "splits": dataframe["split"].value_counts().to_dict(), | |
| "actors_per_split": dataframe.groupby("split")["actor_id"] | |
| .nunique() | |
| .to_dict(), | |
| "emotion_labels": dataframe["emotion_label"].value_counts().to_dict(), | |
| "ravdess_original_emotions": dataframe["ravdess_emotion_label"] | |
| .value_counts() | |
| .to_dict(), | |
| "sentiment_labels": dataframe["sentiment_label"].value_counts().to_dict(), | |
| "notes": [ | |
| "Surprised samples are skipped because the project emotion schema does not include surprise.", | |
| "Calm samples are mapped to neutral.", | |
| "Split is speaker-aware, so each actor belongs to only one split.", | |
| ], | |
| } | |
| return summary | |
| def save_metadata_and_summary(dataframe: pd.DataFrame, summary: Dict) -> None: | |
| """ | |
| Save metadata CSV and summary JSON. | |
| """ | |
| PROCESSED_DIR.mkdir(parents=True, exist_ok=True) | |
| dataframe.to_csv(RAVDESS_METADATA_PATH, index=False) | |
| with RAVDESS_SUMMARY_PATH.open("w", encoding="utf-8") as file: | |
| json.dump(summary, file, indent=2) | |
| def main() -> None: | |
| dataframe = build_ravdess_metadata() | |
| summary = create_summary(dataframe) | |
| save_metadata_and_summary(dataframe, summary) | |
| print("\nRAVDESS metadata preparation completed successfully.") | |
| print("-" * 70) | |
| print(f"Total usable audio files: {summary['total_records']}") | |
| print(f"Split distribution: {summary['splits']}") | |
| print(f"Actors by split: {summary['actors_per_split']}") | |
| print(f"Emotion distribution: {summary['emotion_labels']}") | |
| print(f"Original RAVDESS emotions: {summary['ravdess_original_emotions']}") | |
| print(f"Sentiment distribution: {summary['sentiment_labels']}") | |
| print("-" * 70) | |
| print(f"Saved metadata CSV to: {RAVDESS_METADATA_PATH}") | |
| print(f"Saved summary JSON to: {RAVDESS_SUMMARY_PATH}") | |
| if __name__ == "__main__": | |
| main() |