OpenSTBench 0.2.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1318 @@
1
+ import json
2
+ from abc import ABC, abstractmethod
3
+ from dataclasses import dataclass, field
4
+ from pathlib import Path
5
+ from typing import Any, Callable, Dict, List, Optional, Sequence, Tuple, Union
6
+
7
+ import numpy as np
8
+ import torch
9
+ import torchaudio
10
+ from tqdm import tqdm
11
+
12
+
13
+ LabelNormalizer = Optional[Union[Dict[str, Optional[str]], Callable[[str], Optional[str]]]]
14
+
15
+
16
+ @dataclass(frozen=True)
17
+ class ParalinguisticSample:
18
+ sample_id: str
19
+ source_audio: str
20
+ source_text: str = ""
21
+ source_label: Optional[str] = None
22
+ source_onset_ms: Optional[float] = None
23
+ source_offset_ms: Optional[float] = None
24
+ metadata: Dict[str, Any] = field(default_factory=dict)
25
+
26
+
27
+ @dataclass(frozen=True)
28
+ class EventPrediction:
29
+ label: Optional[str]
30
+ score: Optional[float] = None
31
+ scores: Dict[str, float] = field(default_factory=dict)
32
+
33
+ def to_dict(self) -> Dict[str, Any]:
34
+ return {
35
+ "label": self.label,
36
+ "score": None if self.score is None else float(self.score),
37
+ "scores": {str(key): float(value) for key, value in self.scores.items()},
38
+ }
39
+
40
+
41
+ @dataclass(frozen=True)
42
+ class EventPredictionConfig:
43
+ score_threshold: float = 0.2
44
+ fallback_top1: bool = False
45
+
46
+ def to_dict(self) -> Dict[str, Any]:
47
+ return {
48
+ "score_threshold": float(self.score_threshold),
49
+ "fallback_top1": bool(self.fallback_top1),
50
+ }
51
+
52
+
53
+ @dataclass(frozen=True)
54
+ class EventLocalization:
55
+ label: Optional[str]
56
+ onset_ms: Optional[float] = None
57
+ offset_ms: Optional[float] = None
58
+ score: Optional[float] = None
59
+
60
+ def to_dict(self) -> Dict[str, Any]:
61
+ return {
62
+ "label": self.label,
63
+ "onset_ms": None if self.onset_ms is None else float(self.onset_ms),
64
+ "offset_ms": None if self.offset_ms is None else float(self.offset_ms),
65
+ "score": None if self.score is None else float(self.score),
66
+ }
67
+
68
+
69
+ @dataclass(frozen=True)
70
+ class EventLocalizationConfig:
71
+ window_ms: float = 320.0
72
+ hop_ms: float = 40.0
73
+ score_threshold: Optional[float] = None
74
+ fallback_top1: Optional[bool] = None
75
+
76
+ def to_dict(self) -> Dict[str, Any]:
77
+ return {
78
+ "window_ms": float(self.window_ms),
79
+ "hop_ms": float(self.hop_ms),
80
+ "score_threshold": None if self.score_threshold is None else float(self.score_threshold),
81
+ "fallback_top1": None if self.fallback_top1 is None else bool(self.fallback_top1),
82
+ }
83
+
84
+
85
+ @dataclass(frozen=True)
86
+ class EventAlignmentConfig:
87
+ relative_onset_tolerance: float = 0.15
88
+
89
+ def to_dict(self) -> Dict[str, Any]:
90
+ return {
91
+ "relative_onset_tolerance": float(self.relative_onset_tolerance),
92
+ }
93
+
94
+
95
+ class BaseAudioEventPredictor(ABC):
96
+ @abstractmethod
97
+ def predict(
98
+ self,
99
+ audio_paths: Sequence[str],
100
+ candidate_labels: Sequence[str],
101
+ ) -> List[EventPrediction]:
102
+ raise NotImplementedError
103
+
104
+
105
+ class BaseAudioEventLocalizer(ABC):
106
+ @abstractmethod
107
+ def localize(
108
+ self,
109
+ audio_paths: Sequence[str],
110
+ labels: Sequence[Optional[str]],
111
+ candidate_labels: Sequence[str],
112
+ ) -> List[EventLocalization]:
113
+ raise NotImplementedError
114
+
115
+
116
+ def ensure_existing_audio(path: str) -> str:
117
+ audio_path = Path(path)
118
+ if not audio_path.exists():
119
+ raise FileNotFoundError(f"Audio not found: {path}")
120
+ return str(audio_path.resolve())
121
+
122
+
123
+ def _to_device(device: Optional[str]) -> str:
124
+ if device:
125
+ return device
126
+ return "cuda" if torch.cuda.is_available() else "cpu"
127
+
128
+
129
+ def _load_audio_mono(audio_path: str, target_sr: Optional[int] = None) -> Tuple[np.ndarray, int]:
130
+ path = Path(audio_path)
131
+ if not path.exists():
132
+ raise FileNotFoundError(f"Audio not found: {audio_path}")
133
+
134
+ waveform, sr = torchaudio.load(str(path))
135
+ if waveform.shape[0] > 1:
136
+ waveform = waveform.mean(dim=0, keepdim=True)
137
+ if target_sr is not None and sr != target_sr:
138
+ waveform = torchaudio.functional.resample(waveform, sr, target_sr)
139
+ sr = target_sr
140
+ waveform = waveform.squeeze(0).contiguous().float().cpu().numpy()
141
+ return waveform, int(sr)
142
+
143
+
144
+ def _get_audio_duration_ms(audio_path: str) -> float:
145
+ try:
146
+ info = torchaudio.info(str(audio_path))
147
+ if info.sample_rate > 0:
148
+ return float(info.num_frames / info.sample_rate * 1000.0)
149
+ except Exception:
150
+ pass
151
+
152
+ waveform, sr = _load_audio_mono(audio_path)
153
+ if sr <= 0:
154
+ return 0.0
155
+ return float(len(waveform) / sr * 1000.0)
156
+
157
+
158
+ def _normalize_text_label(label: str) -> str:
159
+ return " ".join(str(label).strip().split())
160
+
161
+
162
+ def _apply_label_normalizer(label: Optional[str], label_normalizer: LabelNormalizer) -> Optional[str]:
163
+ if label is None:
164
+ return None
165
+
166
+ normalized = _normalize_text_label(label)
167
+ if not normalized:
168
+ return None
169
+
170
+ if label_normalizer is None:
171
+ mapped = normalized
172
+ elif callable(label_normalizer):
173
+ mapped = label_normalizer(normalized)
174
+ else:
175
+ mapped = label_normalizer.get(normalized, normalized)
176
+
177
+ if mapped is None:
178
+ return None
179
+ mapped_text = _normalize_text_label(str(mapped))
180
+ return mapped_text or None
181
+
182
+
183
+ def _coerce_manifest_source_label(raw_label: Any, index: int) -> Optional[str]:
184
+ if raw_label is None:
185
+ return None
186
+
187
+ if isinstance(raw_label, list):
188
+ cleaned = []
189
+ for item in raw_label:
190
+ label = _normalize_text_label(str(item))
191
+ if label and label not in cleaned:
192
+ cleaned.append(label)
193
+ if not cleaned:
194
+ return None
195
+ if len(cleaned) > 1:
196
+ raise ValueError(
197
+ f"Manifest item {index} has multiple source labels {cleaned}. "
198
+ "This evaluator expects at most one source event label per sample."
199
+ )
200
+ return cleaned[0]
201
+
202
+ label = _normalize_text_label(str(raw_label))
203
+ return label or None
204
+
205
+
206
+ def _coerce_optional_float(raw_value: Any, *, name: str, index: int) -> Optional[float]:
207
+ if raw_value is None:
208
+ return None
209
+ if isinstance(raw_value, bool):
210
+ raise ValueError(f"{name} for manifest item {index} must be numeric or null.")
211
+ try:
212
+ value = float(raw_value)
213
+ except (TypeError, ValueError) as exc:
214
+ raise ValueError(f"{name} for manifest item {index} must be numeric or null.") from exc
215
+ if value < 0.0:
216
+ raise ValueError(f"{name} for manifest item {index} must be non-negative.")
217
+ return value
218
+
219
+
220
+ def _normalize_label_batch(
221
+ labels: Optional[Sequence[Optional[str]]],
222
+ *,
223
+ name: str,
224
+ expected_length: int,
225
+ label_normalizer: LabelNormalizer,
226
+ ) -> Optional[List[Optional[str]]]:
227
+ if labels is None:
228
+ return None
229
+ if len(labels) != expected_length:
230
+ raise ValueError(f"{name} size mismatch: {len(labels)} vs {expected_length}")
231
+
232
+ normalized: List[Optional[str]] = []
233
+ for index, label in enumerate(labels):
234
+ if label is None:
235
+ normalized.append(None)
236
+ continue
237
+ if not isinstance(label, str):
238
+ raise ValueError(f"{name}[{index}] must be a string or None.")
239
+ normalized.append(_apply_label_normalizer(label, label_normalizer))
240
+ return normalized
241
+
242
+
243
+ def _normalize_float_batch(
244
+ values: Optional[Sequence[Optional[Union[int, float]]]],
245
+ *,
246
+ name: str,
247
+ expected_length: int,
248
+ ) -> Optional[List[Optional[float]]]:
249
+ if values is None:
250
+ return None
251
+ if len(values) != expected_length:
252
+ raise ValueError(f"{name} size mismatch: {len(values)} vs {expected_length}")
253
+
254
+ normalized: List[Optional[float]] = []
255
+ for index, value in enumerate(values):
256
+ if value is None:
257
+ normalized.append(None)
258
+ continue
259
+ if isinstance(value, bool):
260
+ raise ValueError(f"{name}[{index}] must be numeric or None.")
261
+ try:
262
+ float_value = float(value)
263
+ except (TypeError, ValueError) as exc:
264
+ raise ValueError(f"{name}[{index}] must be numeric or None.") from exc
265
+ if float_value < 0.0:
266
+ raise ValueError(f"{name}[{index}] must be non-negative.")
267
+ normalized.append(float_value)
268
+ return normalized
269
+
270
+
271
+ def _normalize_candidate_labels(
272
+ candidate_labels: Optional[Sequence[str]],
273
+ *,
274
+ label_normalizer: LabelNormalizer,
275
+ ) -> List[str]:
276
+ if candidate_labels is None:
277
+ return []
278
+
279
+ normalized: List[str] = []
280
+ seen = set()
281
+ for label in candidate_labels:
282
+ mapped = _apply_label_normalizer(label, label_normalizer)
283
+ if mapped is None or mapped in seen:
284
+ continue
285
+ normalized.append(mapped)
286
+ seen.add(mapped)
287
+ return normalized
288
+
289
+
290
+ def _resolve_candidate_labels(
291
+ *,
292
+ candidate_labels: Optional[Sequence[str]],
293
+ source_labels: Optional[Sequence[Optional[str]]],
294
+ target_labels: Optional[Sequence[Optional[str]]],
295
+ label_normalizer: LabelNormalizer,
296
+ ) -> List[str]:
297
+ resolved = _normalize_candidate_labels(candidate_labels, label_normalizer=label_normalizer)
298
+ if resolved:
299
+ return resolved
300
+
301
+ seen = set()
302
+ derived: List[str] = []
303
+ for batch in (source_labels, target_labels):
304
+ if batch is None:
305
+ continue
306
+ for label in batch:
307
+ mapped = _apply_label_normalizer(label, label_normalizer)
308
+ if mapped is None or mapped in seen:
309
+ continue
310
+ derived.append(mapped)
311
+ seen.add(mapped)
312
+ return derived
313
+
314
+
315
+ def _load_data_list(data: Union[List[str], str], name: str) -> List[str]:
316
+ if isinstance(data, str):
317
+ path = Path(data)
318
+ if path.exists() and path.is_dir():
319
+ return load_audio_from_folder(str(path))
320
+ return [ensure_existing_audio(data)]
321
+ if not isinstance(data, list):
322
+ raise ValueError(f"{name} must be a path or a list of paths.")
323
+ return [ensure_existing_audio(str(item)) for item in data]
324
+
325
+
326
+ def load_audio_from_folder(folder_path: str) -> List[str]:
327
+ folder = Path(folder_path)
328
+ if not folder.exists():
329
+ raise FileNotFoundError(f"Folder not found: {folder_path}")
330
+ if not folder.is_dir():
331
+ raise ValueError(f"Expected a directory, got: {folder_path}")
332
+
333
+ audio_files: List[Path] = []
334
+ for extension in (".wav", ".flac", ".mp3", ".ogg", ".m4a"):
335
+ audio_files.extend(folder.glob(f"*{extension}"))
336
+ audio_files = sorted(audio_files, key=lambda item: item.stem)
337
+ if not audio_files:
338
+ raise ValueError(f"No audio files found under: {folder_path}")
339
+ return [str(item.resolve()) for item in audio_files]
340
+
341
+
342
+ def _safe_mean(values: Sequence[float]) -> float:
343
+ return round(float(sum(values) / len(values)), 4) if values else 0.0
344
+
345
+
346
+ def _compute_single_label_metrics(
347
+ reference_labels: Sequence[Optional[str]],
348
+ predicted_labels: Sequence[Optional[str]],
349
+ *,
350
+ class_labels: Sequence[str],
351
+ ) -> Dict[str, Any]:
352
+ evaluated_indices = [index for index, label in enumerate(reference_labels) if label is not None]
353
+ evaluated_reference = [reference_labels[index] for index in evaluated_indices]
354
+ evaluated_prediction = [predicted_labels[index] for index in evaluated_indices]
355
+
356
+ total = len(evaluated_reference)
357
+ correct = sum(
358
+ 1
359
+ for reference_label, predicted_label in zip(evaluated_reference, evaluated_prediction)
360
+ if reference_label == predicted_label
361
+ )
362
+ abstained = sum(1 for predicted_label in evaluated_prediction if predicted_label is None)
363
+
364
+ unique_class_labels = list(dict.fromkeys([label for label in class_labels if label]))
365
+ if not unique_class_labels:
366
+ unique_class_labels = sorted(
367
+ {
368
+ label
369
+ for label in evaluated_reference + evaluated_prediction
370
+ if label is not None
371
+ }
372
+ )
373
+
374
+ per_label: Dict[str, Dict[str, float]] = {}
375
+ macro_f1_values: List[float] = []
376
+ macro_recall_values: List[float] = []
377
+ confusion: Dict[str, Dict[str, int]] = {}
378
+
379
+ for reference_label, predicted_label in zip(evaluated_reference, evaluated_prediction):
380
+ predicted_key = predicted_label if predicted_label is not None else "__none__"
381
+ confusion.setdefault(str(reference_label), {})
382
+ confusion[str(reference_label)][predicted_key] = confusion[str(reference_label)].get(predicted_key, 0) + 1
383
+
384
+ for label in unique_class_labels:
385
+ tp = sum(
386
+ 1
387
+ for reference_label, predicted_label in zip(evaluated_reference, evaluated_prediction)
388
+ if reference_label == label and predicted_label == label
389
+ )
390
+ fp = sum(
391
+ 1
392
+ for reference_label, predicted_label in zip(evaluated_reference, evaluated_prediction)
393
+ if reference_label != label and predicted_label == label
394
+ )
395
+ fn = sum(
396
+ 1
397
+ for reference_label, predicted_label in zip(evaluated_reference, evaluated_prediction)
398
+ if reference_label == label and predicted_label != label
399
+ )
400
+ support = sum(1 for reference_label in evaluated_reference if reference_label == label)
401
+
402
+ precision = float(tp / (tp + fp)) if (tp + fp) > 0 else 0.0
403
+ recall = float(tp / (tp + fn)) if (tp + fn) > 0 else 0.0
404
+ f1 = float((2 * tp) / (2 * tp + fp + fn)) if (2 * tp + fp + fn) > 0 else 0.0
405
+
406
+ per_label[label] = {
407
+ "support": int(support),
408
+ "tp": int(tp),
409
+ "fp": int(fp),
410
+ "fn": int(fn),
411
+ "precision": round(precision, 4),
412
+ "recall": round(recall, 4),
413
+ "f1": round(f1, 4),
414
+ }
415
+ macro_f1_values.append(f1)
416
+ macro_recall_values.append(recall)
417
+
418
+ return {
419
+ "num_evaluated": int(total),
420
+ "num_skipped": int(len(reference_labels) - total),
421
+ "num_correct": int(correct),
422
+ "num_abstained": int(abstained),
423
+ "preservation_rate": round(float(correct / total), 4) if total > 0 else 0.0,
424
+ "macro_f1": _safe_mean(macro_f1_values),
425
+ "macro_recall": _safe_mean(macro_recall_values),
426
+ "per_label": per_label,
427
+ "confusion_matrix": confusion,
428
+ }
429
+
430
+
431
+ def _compute_alignment_metrics(
432
+ reference_labels: Sequence[Optional[str]],
433
+ predicted_labels: Sequence[Optional[str]],
434
+ source_onsets_ms: Sequence[Optional[float]],
435
+ target_onsets_ms: Sequence[Optional[float]],
436
+ source_durations_ms: Sequence[float],
437
+ target_durations_ms: Sequence[float],
438
+ *,
439
+ relative_onset_tolerance: float,
440
+ sample_ids: Optional[Sequence[str]] = None,
441
+ ) -> Dict[str, Any]:
442
+ evaluated_indices = [
443
+ index
444
+ for index, label in enumerate(reference_labels)
445
+ if label is not None and source_onsets_ms[index] is not None and source_durations_ms[index] > 0.0
446
+ ]
447
+
448
+ aligned_success = 0
449
+ missing_target_onset = 0
450
+ conditional_errors: List[float] = []
451
+ sample_records: List[Dict[str, Any]] = []
452
+
453
+ for index in evaluated_indices:
454
+ reference_label = reference_labels[index]
455
+ predicted_label = predicted_labels[index]
456
+ source_onset_ms = source_onsets_ms[index]
457
+ target_onset_ms = target_onsets_ms[index]
458
+ source_duration_ms = float(source_durations_ms[index])
459
+ target_duration_ms = float(target_durations_ms[index])
460
+
461
+ assert reference_label is not None
462
+ assert source_onset_ms is not None
463
+
464
+ source_relative_onset = float(source_onset_ms / source_duration_ms) if source_duration_ms > 0.0 else None
465
+ target_relative_onset = (
466
+ float(target_onset_ms / target_duration_ms)
467
+ if target_onset_ms is not None and target_duration_ms > 0.0
468
+ else None
469
+ )
470
+ label_correct = predicted_label is not None and predicted_label == reference_label
471
+
472
+ relative_onset_error: Optional[float] = None
473
+ aligned = False
474
+ if label_correct and target_relative_onset is not None and source_relative_onset is not None:
475
+ relative_onset_error = abs(source_relative_onset - target_relative_onset)
476
+ conditional_errors.append(relative_onset_error)
477
+ aligned = relative_onset_error <= relative_onset_tolerance
478
+ elif label_correct and target_onset_ms is None:
479
+ missing_target_onset += 1
480
+
481
+ if aligned:
482
+ aligned_success += 1
483
+
484
+ sample_records.append(
485
+ {
486
+ "sample_index": int(index),
487
+ "sample_id": sample_ids[index] if sample_ids is not None else str(index),
488
+ "reference_label": reference_label,
489
+ "predicted_label": predicted_label,
490
+ "source_onset_ms": float(source_onset_ms),
491
+ "target_onset_ms": None if target_onset_ms is None else float(target_onset_ms),
492
+ "source_duration_ms": round(source_duration_ms, 4),
493
+ "target_duration_ms": round(target_duration_ms, 4),
494
+ "source_relative_onset": None if source_relative_onset is None else round(source_relative_onset, 4),
495
+ "target_relative_onset": None if target_relative_onset is None else round(target_relative_onset, 4),
496
+ "relative_onset_error": None if relative_onset_error is None else round(relative_onset_error, 4),
497
+ "label_correct": bool(label_correct),
498
+ "aligned": bool(aligned),
499
+ }
500
+ )
501
+
502
+ total = len(evaluated_indices)
503
+ return {
504
+ "num_evaluated": int(total),
505
+ "num_skipped": int(len(reference_labels) - total),
506
+ "num_aligned": int(aligned_success),
507
+ "num_missing_target_onset": int(missing_target_onset),
508
+ "num_conditionally_evaluated": int(len(conditional_errors)),
509
+ "aligned_preservation_rate": round(float(aligned_success / total), 4) if total > 0 else 0.0,
510
+ "conditional_relative_onset_error": _safe_mean(conditional_errors),
511
+ "relative_onset_tolerance": round(float(relative_onset_tolerance), 4),
512
+ "samples": sample_records,
513
+ }
514
+
515
+
516
+ class ClapAudioEventPredictor(BaseAudioEventPredictor):
517
+ PROMPT_TEMPLATES = (
518
+ "{label}",
519
+ "the sound of {label}",
520
+ "an audio recording of {label}",
521
+ "a person producing {label}",
522
+ )
523
+
524
+ def __init__(
525
+ self,
526
+ *,
527
+ model_path: str = "laion/clap-htsat-fused",
528
+ config: Optional[EventPredictionConfig] = None,
529
+ device: Optional[str] = None,
530
+ ) -> None:
531
+ self.model_path = model_path
532
+ self.config = config or EventPredictionConfig()
533
+ self.device = _to_device(device)
534
+ self._processor = None
535
+ self._model = None
536
+ self._text_embedding_cache: Dict[str, np.ndarray] = {}
537
+
538
+ def _load_model(self) -> None:
539
+ if self._model is not None and self._processor is not None:
540
+ return
541
+
542
+ try:
543
+ from transformers import ClapModel, ClapProcessor
544
+ except ImportError as exc:
545
+ raise RuntimeError("CLAP-based event prediction requires `transformers`.") from exc
546
+
547
+ self._processor = ClapProcessor.from_pretrained(self.model_path)
548
+ self._model = ClapModel.from_pretrained(self.model_path).to(self.device).eval()
549
+
550
+ @staticmethod
551
+ def _normalize_embedding(embedding: np.ndarray) -> np.ndarray:
552
+ norm = float(np.linalg.norm(embedding))
553
+ if norm <= 0.0:
554
+ return embedding
555
+ return embedding / norm
556
+
557
+ def _build_prompts(self, candidate_labels: Sequence[str]) -> List[Tuple[str, str]]:
558
+ prompt_records: List[Tuple[str, str]] = []
559
+ for label in candidate_labels:
560
+ for template in self.PROMPT_TEMPLATES:
561
+ prompt_records.append((label, template.format(label=label)))
562
+ return prompt_records
563
+
564
+ def _extract_audio_embeddings_from_waveforms(
565
+ self,
566
+ waveforms: Sequence[np.ndarray],
567
+ *,
568
+ sampling_rate: int,
569
+ ) -> List[np.ndarray]:
570
+ self._load_model()
571
+ assert self._processor is not None
572
+ assert self._model is not None
573
+
574
+ embeddings: List[np.ndarray] = []
575
+ for waveform in waveforms:
576
+ inputs = self._processor(audio=waveform, sampling_rate=sampling_rate, return_tensors="pt")
577
+ inputs = {key: value.to(self.device) for key, value in inputs.items()}
578
+ with torch.no_grad():
579
+ features = self._model.get_audio_features(**inputs)
580
+ embeddings.append(features[0].detach().cpu().numpy())
581
+ return embeddings
582
+
583
+ def _extract_audio_embeddings(self, audio_paths: Sequence[str]) -> List[np.ndarray]:
584
+ embeddings: List[np.ndarray] = []
585
+ for audio_path in tqdm(audio_paths, desc="Extracting CLAP event embeddings", unit="file"):
586
+ waveform, sr = _load_audio_mono(str(audio_path), target_sr=48000)
587
+ embeddings.extend(self._extract_audio_embeddings_from_waveforms([waveform], sampling_rate=sr))
588
+ return embeddings
589
+
590
+ def _extract_text_embeddings(self, texts: Sequence[str]) -> List[np.ndarray]:
591
+ self._load_model()
592
+ assert self._processor is not None
593
+ assert self._model is not None
594
+
595
+ embeddings: List[np.ndarray] = []
596
+ for text in texts:
597
+ cached = self._text_embedding_cache.get(text)
598
+ if cached is not None:
599
+ embeddings.append(cached)
600
+ continue
601
+ inputs = self._processor(text=[str(text)], return_tensors="pt", padding=True)
602
+ inputs = {key: value.to(self.device) for key, value in inputs.items()}
603
+ with torch.no_grad():
604
+ features = self._model.get_text_features(**inputs)
605
+ embedding = features[0].detach().cpu().numpy()
606
+ self._text_embedding_cache[text] = embedding
607
+ embeddings.append(embedding)
608
+ return embeddings
609
+
610
+ def _score_audio_embeddings(
611
+ self,
612
+ audio_embeddings: Sequence[np.ndarray],
613
+ candidate_labels: Sequence[str],
614
+ ) -> List[Dict[str, float]]:
615
+ normalized_candidate_labels = [
616
+ _normalize_text_label(label)
617
+ for label in candidate_labels
618
+ if _normalize_text_label(label)
619
+ ]
620
+ unique_candidate_labels = list(dict.fromkeys(normalized_candidate_labels))
621
+ if not unique_candidate_labels:
622
+ return [{} for _ in audio_embeddings]
623
+
624
+ prompt_records = self._build_prompts(unique_candidate_labels)
625
+ normalized_audio_embeddings = [self._normalize_embedding(item) for item in audio_embeddings]
626
+ normalized_text_embeddings = [
627
+ self._normalize_embedding(item)
628
+ for item in self._extract_text_embeddings([prompt for _, prompt in prompt_records])
629
+ ]
630
+
631
+ scores_per_audio: List[Dict[str, float]] = []
632
+ for audio_embedding in normalized_audio_embeddings:
633
+ label_scores: Dict[str, float] = {}
634
+ for (label, _prompt), text_embedding in zip(prompt_records, normalized_text_embeddings):
635
+ score = float(np.dot(audio_embedding, text_embedding))
636
+ if label not in label_scores or score > label_scores[label]:
637
+ label_scores[label] = score
638
+ scores_per_audio.append(label_scores)
639
+ return scores_per_audio
640
+
641
+ def score_audio_paths(
642
+ self,
643
+ audio_paths: Sequence[str],
644
+ candidate_labels: Sequence[str],
645
+ ) -> List[Dict[str, float]]:
646
+ audio_embeddings = self._extract_audio_embeddings(audio_paths)
647
+ return self._score_audio_embeddings(audio_embeddings, candidate_labels)
648
+
649
+ def score_waveforms(
650
+ self,
651
+ waveforms: Sequence[np.ndarray],
652
+ *,
653
+ sampling_rate: int,
654
+ candidate_labels: Sequence[str],
655
+ ) -> List[Dict[str, float]]:
656
+ audio_embeddings = self._extract_audio_embeddings_from_waveforms(waveforms, sampling_rate=sampling_rate)
657
+ return self._score_audio_embeddings(audio_embeddings, candidate_labels)
658
+
659
+ def predict(
660
+ self,
661
+ audio_paths: Sequence[str],
662
+ candidate_labels: Sequence[str],
663
+ ) -> List[EventPrediction]:
664
+ label_scores_per_audio = self.score_audio_paths(audio_paths, candidate_labels)
665
+
666
+ predictions: List[EventPrediction] = []
667
+ threshold = float(self.config.score_threshold)
668
+ fallback_top1 = bool(self.config.fallback_top1)
669
+
670
+ for label_scores in label_scores_per_audio:
671
+ if not label_scores:
672
+ predictions.append(EventPrediction(label=None, score=None, scores={}))
673
+ continue
674
+
675
+ top_label = max(label_scores.items(), key=lambda item: item[1])[0]
676
+ top_score = float(label_scores[top_label])
677
+ predicted_label = top_label if top_score >= threshold or fallback_top1 else None
678
+
679
+ predictions.append(
680
+ EventPrediction(
681
+ label=predicted_label,
682
+ score=top_score,
683
+ scores={label: round(score, 4) for label, score in sorted(label_scores.items())},
684
+ )
685
+ )
686
+
687
+ return predictions
688
+
689
+
690
+ class ClapSlidingWindowEventLocalizer(BaseAudioEventLocalizer):
691
+ def __init__(
692
+ self,
693
+ *,
694
+ model_path: str = "laion/clap-htsat-fused",
695
+ prediction_config: Optional[EventPredictionConfig] = None,
696
+ localization_config: Optional[EventLocalizationConfig] = None,
697
+ device: Optional[str] = None,
698
+ ) -> None:
699
+ self.predictor = ClapAudioEventPredictor(
700
+ model_path=model_path,
701
+ config=prediction_config,
702
+ device=device,
703
+ )
704
+ self.prediction_config = prediction_config or EventPredictionConfig()
705
+ self.localization_config = localization_config or EventLocalizationConfig()
706
+
707
+ def _build_windows(self, waveform: np.ndarray, sampling_rate: int) -> Tuple[List[np.ndarray], List[int]]:
708
+ window_samples = max(1, int(round(self.localization_config.window_ms / 1000.0 * sampling_rate)))
709
+ hop_samples = max(1, int(round(self.localization_config.hop_ms / 1000.0 * sampling_rate)))
710
+ total_samples = int(len(waveform))
711
+
712
+ if total_samples <= window_samples:
713
+ return [waveform], [0]
714
+
715
+ starts: List[int] = list(range(0, max(total_samples - window_samples + 1, 1), hop_samples))
716
+ last_start = max(total_samples - window_samples, 0)
717
+ if not starts or starts[-1] != last_start:
718
+ starts.append(last_start)
719
+
720
+ segments = [waveform[start : start + window_samples] for start in starts]
721
+ return segments, starts
722
+
723
+ def localize(
724
+ self,
725
+ audio_paths: Sequence[str],
726
+ labels: Sequence[Optional[str]],
727
+ candidate_labels: Sequence[str],
728
+ ) -> List[EventLocalization]:
729
+ if len(audio_paths) != len(labels):
730
+ raise ValueError(f"audio_paths size mismatch: {len(audio_paths)} vs labels {len(labels)}")
731
+
732
+ normalized_candidate_labels = {
733
+ _normalize_text_label(label)
734
+ for label in candidate_labels
735
+ if _normalize_text_label(label)
736
+ }
737
+ threshold = (
738
+ float(self.localization_config.score_threshold)
739
+ if self.localization_config.score_threshold is not None
740
+ else float(self.prediction_config.score_threshold)
741
+ )
742
+ fallback_top1 = (
743
+ bool(self.localization_config.fallback_top1)
744
+ if self.localization_config.fallback_top1 is not None
745
+ else bool(self.prediction_config.fallback_top1)
746
+ )
747
+
748
+ localizations: List[EventLocalization] = []
749
+ for audio_path, label in tqdm(
750
+ list(zip(audio_paths, labels)),
751
+ desc="Localizing acoustic events",
752
+ unit="file",
753
+ ):
754
+ normalized_label = _normalize_text_label(label) if isinstance(label, str) and label else None
755
+ if normalized_label is None or (
756
+ normalized_candidate_labels and normalized_label not in normalized_candidate_labels
757
+ ):
758
+ localizations.append(EventLocalization(label=normalized_label))
759
+ continue
760
+
761
+ waveform, sampling_rate = _load_audio_mono(str(audio_path), target_sr=48000)
762
+ if waveform.size == 0:
763
+ localizations.append(EventLocalization(label=normalized_label))
764
+ continue
765
+
766
+ segments, starts = self._build_windows(waveform, sampling_rate)
767
+ label_scores_per_window = self.predictor.score_waveforms(
768
+ segments,
769
+ sampling_rate=sampling_rate,
770
+ candidate_labels=[normalized_label],
771
+ )
772
+ best_start = 0
773
+ best_score = None
774
+ best_index = -1
775
+ for index, label_scores in enumerate(label_scores_per_window):
776
+ score = label_scores.get(normalized_label)
777
+ if score is None:
778
+ continue
779
+ if best_score is None or score > best_score:
780
+ best_score = float(score)
781
+ best_start = starts[index]
782
+ best_index = index
783
+
784
+ if best_score is None or (best_score < threshold and not fallback_top1):
785
+ localizations.append(EventLocalization(label=normalized_label, score=best_score))
786
+ continue
787
+
788
+ window_samples = len(segments[best_index]) if best_index >= 0 else 0
789
+ onset_ms = float(best_start / sampling_rate * 1000.0)
790
+ offset_ms = float(min(best_start + window_samples, len(waveform)) / sampling_rate * 1000.0)
791
+ localizations.append(
792
+ EventLocalization(
793
+ label=normalized_label,
794
+ onset_ms=onset_ms,
795
+ offset_ms=offset_ms,
796
+ score=best_score,
797
+ )
798
+ )
799
+
800
+ return localizations
801
+
802
+
803
+ class ParalinguisticEvaluator:
804
+ DEFAULT_CLAP_MODEL = "laion/clap-htsat-fused"
805
+
806
+ def __init__(
807
+ self,
808
+ use_continuous_fidelity: bool = True,
809
+ use_event_preservation: bool = True,
810
+ use_event_alignment: bool = True,
811
+ clap_model_path: Optional[str] = None,
812
+ event_predictor: Optional[BaseAudioEventPredictor] = None,
813
+ event_localizer: Optional[BaseAudioEventLocalizer] = None,
814
+ event_prediction_config: Optional[Dict[str, Any]] = None,
815
+ event_localization_config: Optional[Dict[str, Any]] = None,
816
+ event_alignment_config: Optional[Dict[str, Any]] = None,
817
+ device: Optional[str] = None,
818
+ **_: Any,
819
+ ) -> None:
820
+ self.device = _to_device(device)
821
+ self.use_continuous_fidelity = bool(use_continuous_fidelity)
822
+ self.use_event_preservation = bool(use_event_preservation)
823
+ self.use_event_alignment = bool(use_event_alignment)
824
+ self.clap_model_path = clap_model_path or self.DEFAULT_CLAP_MODEL
825
+ self.event_prediction_config = EventPredictionConfig(**(event_prediction_config or {}))
826
+ self.event_localization_config = EventLocalizationConfig(**(event_localization_config or {}))
827
+ self.event_alignment_config = EventAlignmentConfig(**(event_alignment_config or {}))
828
+ self.event_predictor = event_predictor
829
+ self.event_localizer = event_localizer
830
+
831
+ self._clap_processor = None
832
+ self._clap_model = None
833
+ self._default_predictor: Optional[ClapAudioEventPredictor] = None
834
+ self._default_localizer: Optional[ClapSlidingWindowEventLocalizer] = None
835
+
836
+ def _load_clap(self) -> None:
837
+ if self._clap_model is not None and self._clap_processor is not None:
838
+ return
839
+
840
+ try:
841
+ from transformers import ClapModel, ClapProcessor
842
+ except ImportError as exc:
843
+ raise RuntimeError("Paralinguistic_Fidelity_Cosine requires `transformers` to load CLAP.") from exc
844
+
845
+ self._clap_processor = ClapProcessor.from_pretrained(self.clap_model_path)
846
+ self._clap_model = ClapModel.from_pretrained(self.clap_model_path).to(self.device).eval()
847
+
848
+ def _extract_clap_embeddings(self, audio_paths: Sequence[str]) -> List[np.ndarray]:
849
+ self._load_clap()
850
+ assert self._clap_processor is not None
851
+ assert self._clap_model is not None
852
+
853
+ embeddings: List[np.ndarray] = []
854
+ for audio_path in tqdm(audio_paths, desc="Extracting CLAP embeddings", unit="file"):
855
+ waveform, sr = _load_audio_mono(str(audio_path), target_sr=48000)
856
+ inputs = self._clap_processor(audio=waveform, sampling_rate=sr, return_tensors="pt")
857
+ inputs = {key: value.to(self.device) for key, value in inputs.items()}
858
+ with torch.no_grad():
859
+ features = self._clap_model.get_audio_features(**inputs)
860
+ embeddings.append(features[0].detach().cpu().numpy())
861
+ return embeddings
862
+
863
+ @staticmethod
864
+ def _average_cosine(source_embeddings: Sequence[np.ndarray], target_embeddings: Sequence[np.ndarray]) -> float:
865
+ total = 0.0
866
+ count = 0
867
+ for source_embedding, target_embedding in zip(source_embeddings, target_embeddings):
868
+ source_norm = float(np.linalg.norm(source_embedding))
869
+ target_norm = float(np.linalg.norm(target_embedding))
870
+ if source_norm <= 0.0 or target_norm <= 0.0:
871
+ continue
872
+ total += float(np.dot(source_embedding, target_embedding) / (source_norm * target_norm))
873
+ count += 1
874
+ return round(total / count, 4) if count > 0 else 0.0
875
+
876
+ def _get_event_predictor(self) -> BaseAudioEventPredictor:
877
+ if self.event_predictor is not None:
878
+ return self.event_predictor
879
+ if self._default_predictor is None:
880
+ self._default_predictor = ClapAudioEventPredictor(
881
+ model_path=self.clap_model_path,
882
+ config=self.event_prediction_config,
883
+ device=self.device,
884
+ )
885
+ return self._default_predictor
886
+
887
+ def _get_event_localizer(self) -> BaseAudioEventLocalizer:
888
+ if self.event_localizer is not None:
889
+ return self.event_localizer
890
+ if self._default_localizer is None:
891
+ self._default_localizer = ClapSlidingWindowEventLocalizer(
892
+ model_path=self.clap_model_path,
893
+ prediction_config=self.event_prediction_config,
894
+ localization_config=self.event_localization_config,
895
+ device=self.device,
896
+ )
897
+ return self._default_localizer
898
+
899
+ def _predict_labels(
900
+ self,
901
+ *,
902
+ audio_paths: Sequence[str],
903
+ candidate_labels: Sequence[str],
904
+ label_normalizer: LabelNormalizer,
905
+ ) -> List[EventPrediction]:
906
+ predictor = self._get_event_predictor()
907
+ if not hasattr(predictor, "predict"):
908
+ raise TypeError("event_predictor must expose a `predict(audio_paths, candidate_labels)` method.")
909
+
910
+ predictions = predictor.predict(audio_paths, candidate_labels)
911
+ if len(predictions) != len(audio_paths):
912
+ raise ValueError(
913
+ "event_predictor returned a different number of predictions than audio inputs: "
914
+ f"{len(predictions)} vs {len(audio_paths)}"
915
+ )
916
+
917
+ normalized_predictions: List[EventPrediction] = []
918
+ for prediction in predictions:
919
+ normalized_predictions.append(
920
+ EventPrediction(
921
+ label=_apply_label_normalizer(prediction.label, label_normalizer),
922
+ score=prediction.score,
923
+ scores={
924
+ _apply_label_normalizer(label, label_normalizer) or label: float(score)
925
+ for label, score in prediction.scores.items()
926
+ },
927
+ )
928
+ )
929
+ return normalized_predictions
930
+
931
+ def _localize_events(
932
+ self,
933
+ *,
934
+ audio_paths: Sequence[str],
935
+ labels: Sequence[Optional[str]],
936
+ candidate_labels: Sequence[str],
937
+ label_normalizer: LabelNormalizer,
938
+ ) -> List[EventLocalization]:
939
+ localizer = self._get_event_localizer()
940
+ if not hasattr(localizer, "localize"):
941
+ raise TypeError("event_localizer must expose a `localize(audio_paths, labels, candidate_labels)` method.")
942
+
943
+ localizations = localizer.localize(audio_paths, labels, candidate_labels)
944
+ if len(localizations) != len(audio_paths):
945
+ raise ValueError(
946
+ "event_localizer returned a different number of localizations than audio inputs: "
947
+ f"{len(localizations)} vs {len(audio_paths)}"
948
+ )
949
+
950
+ normalized_localizations: List[EventLocalization] = []
951
+ for localization in localizations:
952
+ normalized_localizations.append(
953
+ EventLocalization(
954
+ label=_apply_label_normalizer(localization.label, label_normalizer),
955
+ onset_ms=localization.onset_ms,
956
+ offset_ms=localization.offset_ms,
957
+ score=localization.score,
958
+ )
959
+ )
960
+ return normalized_localizations
961
+
962
+ def evaluate_all(
963
+ self,
964
+ source_audio: Union[List[str], str],
965
+ target_audio: Union[List[str], str],
966
+ *,
967
+ source_labels: Optional[Sequence[Optional[str]]] = None,
968
+ target_labels: Optional[Sequence[Optional[str]]] = None,
969
+ source_onsets_ms: Optional[Sequence[Optional[Union[int, float]]]] = None,
970
+ target_onsets_ms: Optional[Sequence[Optional[Union[int, float]]]] = None,
971
+ candidate_labels: Optional[Sequence[str]] = None,
972
+ label_normalizer: LabelNormalizer = None,
973
+ sample_ids: Optional[Sequence[str]] = None,
974
+ verbose: bool = True,
975
+ return_diagnostics: bool = False,
976
+ ) -> Union[Tuple[Dict[str, float], Dict[str, Any]], Dict[str, float]]:
977
+ source_audio_paths = _load_data_list(source_audio, "Source Audio")
978
+ target_audio_paths = _load_data_list(target_audio, "Target Audio")
979
+
980
+ if len(source_audio_paths) != len(target_audio_paths):
981
+ raise ValueError(
982
+ f"Source and target size mismatch: {len(source_audio_paths)} vs {len(target_audio_paths)}"
983
+ )
984
+ if not source_audio_paths:
985
+ raise ValueError("No samples found for paralinguistic evaluation.")
986
+
987
+ num_samples = len(source_audio_paths)
988
+ normalized_source_labels = _normalize_label_batch(
989
+ source_labels,
990
+ name="source_labels",
991
+ expected_length=num_samples,
992
+ label_normalizer=label_normalizer,
993
+ )
994
+ normalized_target_labels = _normalize_label_batch(
995
+ target_labels,
996
+ name="target_labels",
997
+ expected_length=num_samples,
998
+ label_normalizer=label_normalizer,
999
+ )
1000
+ normalized_source_onsets_ms = _normalize_float_batch(
1001
+ source_onsets_ms,
1002
+ name="source_onsets_ms",
1003
+ expected_length=num_samples,
1004
+ )
1005
+ normalized_target_onsets_ms = _normalize_float_batch(
1006
+ target_onsets_ms,
1007
+ name="target_onsets_ms",
1008
+ expected_length=num_samples,
1009
+ )
1010
+ resolved_candidate_labels = _resolve_candidate_labels(
1011
+ candidate_labels=candidate_labels,
1012
+ source_labels=normalized_source_labels,
1013
+ target_labels=normalized_target_labels,
1014
+ label_normalizer=label_normalizer,
1015
+ )
1016
+
1017
+ results: Dict[str, float] = {}
1018
+ diagnostics: Dict[str, Any] = {
1019
+ "num_samples": num_samples,
1020
+ "device": self.device,
1021
+ "clap_model_path": self.clap_model_path,
1022
+ }
1023
+
1024
+ if self.use_continuous_fidelity:
1025
+ source_embeddings = self._extract_clap_embeddings(source_audio_paths)
1026
+ target_embeddings = self._extract_clap_embeddings(target_audio_paths)
1027
+ cosine = self._average_cosine(source_embeddings, target_embeddings)
1028
+ results["Paralinguistic_Fidelity_Cosine"] = cosine
1029
+ diagnostics["continuous_fidelity"] = {
1030
+ "metric": "Paralinguistic_Fidelity_Cosine",
1031
+ "num_embeddings": len(source_embeddings),
1032
+ "score": cosine,
1033
+ }
1034
+
1035
+ source_prediction_records: Optional[List[EventPrediction]] = None
1036
+ target_prediction_records: Optional[List[EventPrediction]] = None
1037
+ source_label_origin = "provided_source_labels"
1038
+ target_label_origin = "provided_target_labels"
1039
+
1040
+ if self.use_event_preservation or self.use_event_alignment:
1041
+ if (normalized_source_labels is None or normalized_target_labels is None) and not resolved_candidate_labels:
1042
+ raise ValueError(
1043
+ "Event preservation or alignment requires candidate_labels when either source_labels or "
1044
+ "target_labels are not provided."
1045
+ )
1046
+
1047
+ if normalized_source_labels is None:
1048
+ source_prediction_records = self._predict_labels(
1049
+ audio_paths=source_audio_paths,
1050
+ candidate_labels=resolved_candidate_labels,
1051
+ label_normalizer=label_normalizer,
1052
+ )
1053
+ normalized_source_labels = [prediction.label for prediction in source_prediction_records]
1054
+ source_label_origin = "predicted_source_labels"
1055
+
1056
+ if normalized_target_labels is None:
1057
+ target_prediction_records = self._predict_labels(
1058
+ audio_paths=target_audio_paths,
1059
+ candidate_labels=resolved_candidate_labels,
1060
+ label_normalizer=label_normalizer,
1061
+ )
1062
+ normalized_target_labels = [prediction.label for prediction in target_prediction_records]
1063
+ target_label_origin = "predicted_target_labels"
1064
+
1065
+ if self.use_event_preservation:
1066
+ assert normalized_source_labels is not None
1067
+ assert normalized_target_labels is not None
1068
+
1069
+ metric_payload = _compute_single_label_metrics(
1070
+ normalized_source_labels,
1071
+ normalized_target_labels,
1072
+ class_labels=resolved_candidate_labels,
1073
+ )
1074
+
1075
+ use_predicted_reference = source_labels is None
1076
+ if use_predicted_reference:
1077
+ rate_name = "Predicted_Event_Consistency_Rate"
1078
+ macro_f1_name = "Predicted_Event_Consistency_Macro_F1"
1079
+ macro_recall_name = "Predicted_Event_Consistency_Macro_Recall"
1080
+ else:
1081
+ rate_name = "Acoustic_Event_Preservation_Rate"
1082
+ macro_f1_name = "Acoustic_Event_Preservation_Macro_F1"
1083
+ macro_recall_name = "Acoustic_Event_Preservation_Macro_Recall"
1084
+
1085
+ results[rate_name] = metric_payload["preservation_rate"]
1086
+ results[macro_f1_name] = metric_payload["macro_f1"]
1087
+ results[macro_recall_name] = metric_payload["macro_recall"]
1088
+
1089
+ diagnostics["event_preservation"] = {
1090
+ "candidate_labels": list(resolved_candidate_labels),
1091
+ "source_label_origin": source_label_origin,
1092
+ "target_label_origin": target_label_origin,
1093
+ "config": self.event_prediction_config.to_dict(),
1094
+ "num_evaluated": metric_payload["num_evaluated"],
1095
+ "num_skipped": metric_payload["num_skipped"],
1096
+ "num_abstained": metric_payload["num_abstained"],
1097
+ "per_label": metric_payload["per_label"],
1098
+ "confusion_matrix": metric_payload["confusion_matrix"],
1099
+ "source_predictions": [prediction.to_dict() for prediction in source_prediction_records]
1100
+ if source_prediction_records is not None
1101
+ else None,
1102
+ "target_predictions": [prediction.to_dict() for prediction in target_prediction_records]
1103
+ if target_prediction_records is not None
1104
+ else None,
1105
+ "samples": [
1106
+ {
1107
+ "sample_index": index,
1108
+ "sample_id": sample_ids[index] if sample_ids is not None else str(index),
1109
+ "reference_label": normalized_source_labels[index],
1110
+ "predicted_label": normalized_target_labels[index],
1111
+ "correct": normalized_source_labels[index] is not None
1112
+ and normalized_source_labels[index] == normalized_target_labels[index],
1113
+ }
1114
+ for index in range(num_samples)
1115
+ ],
1116
+ }
1117
+
1118
+ if self.use_event_alignment and normalized_source_onsets_ms is not None:
1119
+ assert normalized_source_labels is not None
1120
+ assert normalized_target_labels is not None
1121
+
1122
+ target_localization_records: Optional[List[EventLocalization]] = None
1123
+ target_onset_origin = "provided_target_onsets_ms"
1124
+
1125
+ if normalized_target_onsets_ms is None:
1126
+ target_localization_records = self._localize_events(
1127
+ audio_paths=target_audio_paths,
1128
+ labels=normalized_target_labels,
1129
+ candidate_labels=resolved_candidate_labels,
1130
+ label_normalizer=label_normalizer,
1131
+ )
1132
+ normalized_target_onsets_ms = [item.onset_ms for item in target_localization_records]
1133
+ target_onset_origin = "localized_target_onsets_ms"
1134
+
1135
+ assert normalized_target_onsets_ms is not None
1136
+
1137
+ source_durations_ms = [_get_audio_duration_ms(path) for path in source_audio_paths]
1138
+ target_durations_ms = [_get_audio_duration_ms(path) for path in target_audio_paths]
1139
+
1140
+ alignment_payload = _compute_alignment_metrics(
1141
+ normalized_source_labels,
1142
+ normalized_target_labels,
1143
+ normalized_source_onsets_ms,
1144
+ normalized_target_onsets_ms,
1145
+ source_durations_ms,
1146
+ target_durations_ms,
1147
+ relative_onset_tolerance=self.event_alignment_config.relative_onset_tolerance,
1148
+ sample_ids=sample_ids,
1149
+ )
1150
+
1151
+ if source_labels is None:
1152
+ alignment_rate_name = "Predicted_Event_Aligned_Consistency_Rate"
1153
+ onset_error_name = "Predicted_Conditional_Relative_Onset_Error"
1154
+ else:
1155
+ alignment_rate_name = "Event_Aligned_Preservation_Rate"
1156
+ onset_error_name = "Conditional_Relative_Onset_Error"
1157
+
1158
+ results[alignment_rate_name] = alignment_payload["aligned_preservation_rate"]
1159
+ results[onset_error_name] = alignment_payload["conditional_relative_onset_error"]
1160
+
1161
+ diagnostics["event_alignment"] = {
1162
+ "candidate_labels": list(resolved_candidate_labels),
1163
+ "source_label_origin": source_label_origin,
1164
+ "target_label_origin": target_label_origin,
1165
+ "target_onset_origin": target_onset_origin,
1166
+ "prediction_config": self.event_prediction_config.to_dict(),
1167
+ "localization_config": self.event_localization_config.to_dict(),
1168
+ "alignment_config": self.event_alignment_config.to_dict(),
1169
+ "source_onsets_ms": normalized_source_onsets_ms,
1170
+ "target_onsets_ms": normalized_target_onsets_ms,
1171
+ "target_localizations": [item.to_dict() for item in target_localization_records]
1172
+ if target_localization_records is not None
1173
+ else None,
1174
+ "num_evaluated": alignment_payload["num_evaluated"],
1175
+ "num_skipped": alignment_payload["num_skipped"],
1176
+ "num_aligned": alignment_payload["num_aligned"],
1177
+ "num_missing_target_onset": alignment_payload["num_missing_target_onset"],
1178
+ "num_conditionally_evaluated": alignment_payload["num_conditionally_evaluated"],
1179
+ "relative_onset_tolerance": alignment_payload["relative_onset_tolerance"],
1180
+ "samples": alignment_payload["samples"],
1181
+ }
1182
+
1183
+ if verbose:
1184
+ print("\n[ParalinguisticEvaluator] Summary")
1185
+ print(f" Samples: {num_samples}")
1186
+ for metric_name, score in results.items():
1187
+ print(f" {metric_name}: {score}")
1188
+
1189
+ if return_diagnostics:
1190
+ return results, diagnostics
1191
+ return results
1192
+
1193
+
1194
+ def load_paralinguistic_manifest(path: str) -> List[ParalinguisticSample]:
1195
+ manifest_path = Path(path)
1196
+ if not manifest_path.exists():
1197
+ raise FileNotFoundError(f"Paralinguistic manifest not found: {path}")
1198
+
1199
+ data = json.loads(manifest_path.read_text(encoding="utf-8"))
1200
+ if not isinstance(data, list):
1201
+ raise ValueError("Paralinguistic manifest must be a JSON list.")
1202
+
1203
+ samples: List[ParalinguisticSample] = []
1204
+ for index, item in enumerate(data):
1205
+ if not isinstance(item, dict):
1206
+ raise ValueError(f"Manifest item {index} must be a dict.")
1207
+
1208
+ source_audio = item.get("source_audio")
1209
+ if not source_audio:
1210
+ raise ValueError(f"Manifest item {index} is missing source_audio.")
1211
+
1212
+ raw_label = item.get("source_label", item.get("label", item.get("labels")))
1213
+ source_label = _coerce_manifest_source_label(raw_label, index)
1214
+ metadata = item.get("metadata") or {}
1215
+ if not isinstance(metadata, dict):
1216
+ metadata = {}
1217
+
1218
+ raw_onset = item.get("source_onset_ms", item.get("onset_ms", item.get("start_ms", metadata.get("source_onset_ms"))))
1219
+ raw_offset = item.get("source_offset_ms", item.get("offset_ms", item.get("end_ms", metadata.get("source_offset_ms"))))
1220
+
1221
+ samples.append(
1222
+ ParalinguisticSample(
1223
+ sample_id=str(item.get("id", index)),
1224
+ source_audio=ensure_existing_audio(str(source_audio)),
1225
+ source_text=str(item.get("source_text", "")).strip(),
1226
+ source_label=source_label,
1227
+ source_onset_ms=_coerce_optional_float(raw_onset, name="source_onset_ms", index=index),
1228
+ source_offset_ms=_coerce_optional_float(raw_offset, name="source_offset_ms", index=index),
1229
+ metadata=metadata,
1230
+ )
1231
+ )
1232
+ return samples
1233
+
1234
+
1235
+ def load_paralinguistic_samples(path: str, max_samples: Optional[int] = None) -> List[ParalinguisticSample]:
1236
+ samples = load_paralinguistic_manifest(path)
1237
+ if max_samples is not None:
1238
+ return samples[:max_samples]
1239
+ return samples
1240
+
1241
+
1242
+ def build_paralinguistic_inputs(samples: List[ParalinguisticSample]) -> Dict[str, List[Any]]:
1243
+ if not samples:
1244
+ raise ValueError("No paralinguistic samples provided.")
1245
+ return {
1246
+ "sample_ids": [sample.sample_id for sample in samples],
1247
+ "source_audio": [sample.source_audio for sample in samples],
1248
+ "source_text": [sample.source_text for sample in samples],
1249
+ "source_labels": [sample.source_label for sample in samples],
1250
+ "source_onsets_ms": [sample.source_onset_ms for sample in samples],
1251
+ "source_offsets_ms": [sample.source_offset_ms for sample in samples],
1252
+ "metadata": [sample.metadata for sample in samples],
1253
+ }
1254
+
1255
+
1256
+ def evaluate_paralinguistic_dataset(
1257
+ target_audio: List[str],
1258
+ *,
1259
+ samples: Optional[List[ParalinguisticSample]] = None,
1260
+ manifest_path: Optional[str] = None,
1261
+ max_samples: Optional[int] = None,
1262
+ evaluator: Optional[ParalinguisticEvaluator] = None,
1263
+ evaluator_kwargs: Optional[Dict[str, Any]] = None,
1264
+ device: Optional[str] = None,
1265
+ return_diagnostics: bool = False,
1266
+ sample_transform: Optional[Callable[[List[ParalinguisticSample]], List[ParalinguisticSample]]] = None,
1267
+ target_labels: Optional[Sequence[Optional[str]]] = None,
1268
+ target_onsets_ms: Optional[Sequence[Optional[Union[int, float]]]] = None,
1269
+ candidate_labels: Optional[Sequence[str]] = None,
1270
+ label_normalizer: LabelNormalizer = None,
1271
+ ) -> Union[Tuple[Dict[str, float], Dict[str, Any]], Dict[str, float]]:
1272
+ if samples is None:
1273
+ if not manifest_path:
1274
+ raise ValueError("manifest_path is required when samples are not provided.")
1275
+ samples = load_paralinguistic_manifest(manifest_path)
1276
+
1277
+ if max_samples is not None:
1278
+ samples = samples[:max_samples]
1279
+ if sample_transform is not None:
1280
+ samples = sample_transform(samples)
1281
+ if not samples:
1282
+ raise ValueError("No paralinguistic samples available.")
1283
+
1284
+ inputs = build_paralinguistic_inputs(samples)
1285
+ if len(inputs["source_audio"]) != len(target_audio):
1286
+ raise ValueError(
1287
+ f"Source/target size mismatch for paralinguistic evaluation: "
1288
+ f"{len(inputs['source_audio'])} vs {len(target_audio)}"
1289
+ )
1290
+
1291
+ if evaluator is None:
1292
+ final_kwargs = dict(evaluator_kwargs or {})
1293
+ final_kwargs.setdefault("use_continuous_fidelity", True)
1294
+ final_kwargs.setdefault("use_event_preservation", True)
1295
+ final_kwargs.setdefault("use_event_alignment", True)
1296
+ evaluator = ParalinguisticEvaluator(device=device, **final_kwargs)
1297
+
1298
+ result = evaluator.evaluate_all(
1299
+ source_audio=inputs["source_audio"],
1300
+ target_audio=target_audio,
1301
+ source_labels=inputs["source_labels"],
1302
+ target_labels=target_labels,
1303
+ source_onsets_ms=inputs["source_onsets_ms"],
1304
+ target_onsets_ms=target_onsets_ms,
1305
+ candidate_labels=candidate_labels,
1306
+ label_normalizer=label_normalizer,
1307
+ sample_ids=inputs["sample_ids"],
1308
+ verbose=True,
1309
+ return_diagnostics=return_diagnostics,
1310
+ )
1311
+
1312
+ if return_diagnostics:
1313
+ scores, diagnostics = result
1314
+ diagnostics["sample_ids"] = inputs["sample_ids"]
1315
+ diagnostics["metadata"] = inputs["metadata"]
1316
+ return scores, diagnostics
1317
+
1318
+ return result