Skip to content

Commit c9ea141

Browse files
satraclaude
andauthored
Address review comments: SPARC, SQUIM, PPG, SpeechBrain (#470)
* Address review comments: SPARC tempdir, SQUIM loop, public PPG API, SB revision - SPARC decode/convert: create Audio from waveform tensor directly, no dangling filepath to deleted tempdir - SQUIM: move model.to(device) before loop (was inside loop) - PPG: rename _extract_ppg_segments → extract_ppg_segments (public), _to_frame_major_posteriorgram → to_frame_major_posteriorgram (public), export from __init__.py, update tutorials and tests - SpeechBrain SER: add revision to cache key, pass revision to hf_hub_download, use yaml.safe_load instead of string splitting Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix SpeechBrain YAML parsing: use permissive loader for custom tags SpeechBrain hyperparams.yaml uses custom YAML tags (!new:, !ref, etc.) that yaml.safe_load cannot handle. Use a dedicated SafeLoader subclass with a multi-constructor that ignores unknown tags. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix test function names: add missing underscore after 'test' prefix 7 test functions were named testXxx instead of test_Xxx, making them invisible to pytest discovery. Now all 14 PPG/segment tests are properly discovered and pass. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent d7cdeab commit c9ea141

8 files changed

Lines changed: 57 additions & 216 deletions

File tree

src/senselab/audio/tasks/classification/speech_emotion_recognition/api.py

Lines changed: 13 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -271,7 +271,7 @@ def _classify_speechbrain_ser(
271271
user_preference=device, compatible_devices=[DeviceType.CUDA, DeviceType.CPU]
272272
)
273273

274-
key = f"{model.path_or_uri}-{device_type.value}"
274+
key = f"{model.path_or_uri}-{model.revision or 'main'}-{device_type.value}"
275275
if key not in _speechbrain_ser_models:
276276
# Discover required modules from the model's hyperparams
277277
_speechbrain_ser_models[key] = _load_speechbrain_ser_model(model, device_type)
@@ -317,24 +317,23 @@ def _load_speechbrain_ser_model(model: SpeechBrainModel, device_type: DeviceType
317317
Returns:
318318
Tuple of (recognizer, label_list).
319319
"""
320+
import yaml # type: ignore[import-untyped]
320321
from huggingface_hub import hf_hub_download
321322
from speechbrain.inference import Pretrained
322323

323324
# Download hyperparams to discover MODULES_NEEDED
324-
hp_path = hf_hub_download(str(model.path_or_uri), "hyperparams.yaml")
325+
hp_path = hf_hub_download(str(model.path_or_uri), "hyperparams.yaml", revision=model.revision)
326+
327+
# SpeechBrain YAML uses custom tags (!new:, !ref, etc.) that yaml.safe_load
328+
# cannot handle. Use a permissive loader subclass that ignores unknown tags.
329+
class _PermissiveLoader(yaml.SafeLoader):
330+
pass
331+
332+
_PermissiveLoader.add_multi_constructor("", lambda loader, suffix, node: None)
325333
with open(hp_path) as f:
326-
content = f.read()
327-
328-
# Extract MODULES_NEEDED from the yaml
329-
modules_needed = None
330-
for line in content.split("\n"):
331-
if "MODULES_NEEDED" in line:
332-
# Parse: MODULES_NEEDED: ["wav2vec2", "avg_pool", "output_mlp"]
333-
import ast
334-
335-
_, _, value = line.partition(":")
336-
modules_needed = ast.literal_eval(value.strip())
337-
break
334+
hparams = yaml.load(f, Loader=_PermissiveLoader) # noqa: S506
335+
336+
modules_needed = hparams.get("MODULES_NEEDED") if hparams else None
338337

339338
if not modules_needed:
340339
raise ValueError(
Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
""".. include:: ./doc.md""" # noqa: D415
22

33
from .api import extract_features_from_audios # noqa: F401
4-
from .ppg import extract_mean_phoneme_durations, plot_ppg_phoneme_timeline # noqa: F401
4+
from .ppg import ( # noqa: F401
5+
extract_mean_phoneme_durations,
6+
extract_ppg_segments,
7+
extract_ppgs_from_audios,
8+
plot_ppg_phoneme_timeline,
9+
to_frame_major_posteriorgram,
10+
)
511
from .sparc import SparcFeatureExtractor # noqa: F401

src/senselab/audio/tasks/features_extraction/ppg.py

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ def extract_ppgs_from_audios(audios: List[Audio], device: Optional[DeviceType] =
201201
# ---------------------------------------------------------------------------
202202

203203

204-
def _to_frame_major_posteriorgram(posteriorgram: torch.Tensor) -> torch.Tensor:
204+
def to_frame_major_posteriorgram(posteriorgram: torch.Tensor) -> torch.Tensor:
205205
"""Normalize a PPG tensor to frame-major layout ``(frames, phonemes)``.
206206
207207
The ppgs library typically returns tensors shaped ``(1, phonemes, frames)``
@@ -241,7 +241,7 @@ def _to_frame_major_posteriorgram(posteriorgram: torch.Tensor) -> torch.Tensor:
241241
return t
242242

243243

244-
def _extract_ppg_segments(
244+
def extract_ppg_segments(
245245
audio: Audio,
246246
frame_major_posteriorgram: torch.Tensor,
247247
) -> List[Dict[str, Any]]:
@@ -358,8 +358,8 @@ def extract_mean_phoneme_durations(
358358
logger.warning("Posteriorgram is empty or contains NaN — returning empty result.")
359359
return {}
360360

361-
frame_major = _to_frame_major_posteriorgram(posteriorgram)
362-
segments = _extract_ppg_segments(audio, frame_major)
361+
frame_major = to_frame_major_posteriorgram(posteriorgram)
362+
segments = extract_ppg_segments(audio, frame_major)
363363

364364
if not segments:
365365
return {}
@@ -432,8 +432,8 @@ def plot_ppg_phoneme_timeline(
432432
if posteriorgram.numel() == 0 or torch.isnan(posteriorgram).any():
433433
raise ValueError("Cannot plot an empty or NaN posteriorgram.")
434434

435-
frame_major = _to_frame_major_posteriorgram(posteriorgram)
436-
segments = _extract_ppg_segments(audio, frame_major)
435+
frame_major = to_frame_major_posteriorgram(posteriorgram)
436+
segments = extract_ppg_segments(audio, frame_major)
437437

438438
if not segments:
439439
raise ValueError("No segments found in posteriorgram.")

src/senselab/audio/tasks/features_extraction/sparc.py

Lines changed: 10 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -370,10 +370,11 @@ def decode_sparc_features(
370370
sample_rate = output["sample_rate"]
371371
logger.info("SPARC decode produced audio at %d Hz: %s", sample_rate, audio_path)
372372

373-
audio = Audio(filepath=audio_path)
374-
# Force lazy-load while the temp file still exists
375-
_ = audio.waveform
376-
return audio
373+
tmp_audio = Audio(filepath=audio_path)
374+
# Force lazy-load while the temp file still exists, then
375+
# create a clean Audio without a filepath pointing at the
376+
# (about-to-be-deleted) temp directory.
377+
return Audio(waveform=tmp_audio.waveform, sampling_rate=tmp_audio.sampling_rate)
377378

378379
@classmethod
379380
def convert_voice(
@@ -448,7 +449,8 @@ def convert_voice(
448449
sample_rate = output["sample_rate"]
449450
logger.info("SPARC convert produced audio at %d Hz: %s", sample_rate, audio_path)
450451

451-
audio = Audio(filepath=audio_path)
452-
# Force lazy-load while the temp file still exists
453-
_ = audio.waveform
454-
return audio
452+
tmp_audio = Audio(filepath=audio_path)
453+
# Force lazy-load while the temp file still exists, then
454+
# create a clean Audio without a filepath pointing at the
455+
# (about-to-be-deleted) temp directory.
456+
return Audio(waveform=tmp_audio.waveform, sampling_rate=tmp_audio.sampling_rate)

src/senselab/audio/tasks/features_extraction/torchaudio_squim.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -66,11 +66,12 @@ def extract_objective_quality_features_from_audios(
6666
raise ValueError("Only 16000 Hz sampling rate is supported by Torchaudio-Squim model.")
6767

6868
features: List[Dict[str, Any]] = []
69+
model = _get_objective_model().to(device.value)
6970

7071
for audio in audios:
7172
audio_features = {}
7273
try:
73-
stoi, pesq, si_sdr = _get_objective_model().to(device.value)(audio.waveform.to(device.value))
74+
stoi, pesq, si_sdr = model(audio.waveform.to(device.value))
7475
audio_features["stoi"] = stoi.cpu().item()
7576
audio_features["pesq"] = pesq.cpu().item()
7677
audio_features["si_sdr"] = si_sdr.cpu().item()

src/tests/audio/tasks/features_extraction_test.py

Lines changed: 14 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,11 @@
99
from senselab.audio.tasks.features_extraction import extract_features_from_audios
1010
from senselab.audio.tasks.features_extraction.opensmile import extract_opensmile_features_from_audios
1111
from senselab.audio.tasks.features_extraction.ppg import (
12-
_extract_ppg_segments,
13-
_to_frame_major_posteriorgram,
1412
extract_mean_phoneme_durations,
13+
extract_ppg_segments,
1514
extract_ppgs_from_audios,
1615
plot_ppg_phoneme_timeline,
16+
to_frame_major_posteriorgram,
1717
)
1818
from senselab.audio.tasks.features_extraction.praat_parselmouth import (
1919
extract_audio_duration,
@@ -419,31 +419,31 @@ def _make_synthetic_ppg(
419419

420420

421421
def test_to_frame_major_posteriorgram_3d() -> None:
422-
"""Test _to_frame_major_posteriorgram with a (1, phonemes, frames) input."""
422+
"""Test to_frame_major_posteriorgram with a (1, phonemes, frames) input."""
423423
ppg = torch.rand(1, 40, 100)
424-
result = _to_frame_major_posteriorgram(ppg)
424+
result = to_frame_major_posteriorgram(ppg)
425425
assert result.shape == (100, 40)
426426

427427

428428
def test_to_frame_major_posteriorgram_2d() -> None:
429-
"""Test _to_frame_major_posteriorgram with a (phonemes, frames) input."""
429+
"""Test to_frame_major_posteriorgram with a (phonemes, frames) input."""
430430
ppg = torch.rand(40, 100)
431-
result = _to_frame_major_posteriorgram(ppg)
431+
result = to_frame_major_posteriorgram(ppg)
432432
assert result.shape == (100, 40)
433433

434434

435435
def test_to_frame_major_posteriorgram_already_frame_major() -> None:
436-
"""Test _to_frame_major_posteriorgram when input is already (frames, phonemes)."""
436+
"""Test to_frame_major_posteriorgram when input is already (frames, phonemes)."""
437437
ppg = torch.rand(100, 40)
438-
result = _to_frame_major_posteriorgram(ppg)
438+
result = to_frame_major_posteriorgram(ppg)
439439
assert result.shape == (100, 40)
440440

441441

442442
def test_to_frame_major_posteriorgram_too_few_dims() -> None:
443443
"""Test that a 1-D tensor raises ValueError."""
444444
ppg = torch.rand(40)
445445
with pytest.raises(ValueError, match="Expected at least a 2-D"):
446-
_to_frame_major_posteriorgram(ppg)
446+
to_frame_major_posteriorgram(ppg)
447447

448448

449449
def test_extract_ppg_segments_basic() -> None:
@@ -452,8 +452,8 @@ def test_extract_ppg_segments_basic() -> None:
452452
pattern = [0] * 10 + [1] * 5
453453
ppg = _make_synthetic_ppg(pattern)
454454
audio = Audio(waveform=torch.rand(1, 16000), sampling_rate=16000)
455-
frame_major = _to_frame_major_posteriorgram(ppg)
456-
segments = _extract_ppg_segments(audio, frame_major)
455+
frame_major = to_frame_major_posteriorgram(ppg)
456+
segments = extract_ppg_segments(audio, frame_major)
457457

458458
assert len(segments) == 2
459459
assert segments[0]["phoneme_index"] == 0
@@ -473,8 +473,8 @@ def test_extract_ppg_segments_single_phoneme() -> None:
473473
pattern = [5] * 20
474474
ppg = _make_synthetic_ppg(pattern)
475475
audio = Audio(waveform=torch.rand(1, 32000), sampling_rate=16000)
476-
frame_major = _to_frame_major_posteriorgram(ppg)
477-
segments = _extract_ppg_segments(audio, frame_major)
476+
frame_major = to_frame_major_posteriorgram(ppg)
477+
segments = extract_ppg_segments(audio, frame_major)
478478

479479
assert len(segments) == 1
480480
assert segments[0]["phoneme_index"] == 5
@@ -485,7 +485,7 @@ def test_extract_ppg_segments_empty() -> None:
485485
"""Test segment extraction with zero frames."""
486486
ppg = torch.rand(40, 0)
487487
audio = Audio(waveform=torch.rand(1, 16000), sampling_rate=16000)
488-
segments = _extract_ppg_segments(audio, ppg)
488+
segments = extract_ppg_segments(audio, ppg)
489489
assert segments == []
490490

491491

0 commit comments

Comments
 (0)