Skip to content

Commit 865d626

Browse files
satraclaude
andauthored
Add YAMNet subprocess backend and auto-resampling for classification (#505)
* Add AST + YAMNet to classification docs and model registry - classification/doc.md: AST and YAMNet added to Models section (organized by category: scene, demographics, content) - model_registry.yaml: new audio_scene_classification section - model_registry.md: regenerated - Fixed generate_model_registry.py path (docs/ → src/senselab/) Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add __all__ to all task/workflow modules for clean pdoc docs Without __all__, pdoc shows internal submodules (api, huggingface, etc.) as navigation items. With __all__, pdoc only shows the public API functions — what users actually need. Applied to 21 task modules + 1 workflow module across audio, video, text, and utils. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix review: YAMNet TF note, music genre link, model registry source - YAMNet: noted as TensorFlow-based, not directly supported via classify_audios - Music genre model link: config.json → main model card - Model registry: YAMNet source changed from huggingface to tensorflow Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Add YAMNet subprocess venv backend and auto-resampling for classification - YAMNet runs in isolated TF subprocess venv (tensorflow, tensorflow-hub) - Accessible via classify_audios(audios, model="yamnet") - Auto-resamples to 16kHz inside the loop (memory-efficient) - HF classifier now auto-resamples instead of erroring on wrong sample rate - Updated doc.md and model_registry to reflect YAMNet as fully supported Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Fix mypy: cast model.path_or_uri to str for hf_hub_download Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> * Apply ruff format to yamnet.py 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 4cc35bd commit 865d626

29 files changed

Lines changed: 299 additions & 28 deletions

File tree

scripts/generate_model_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@
88

99
def main() -> None:
1010
"""Read YAML and output Markdown table."""
11-
registry_path = Path(__file__).parent.parent / "docs" / "model_registry.yaml"
11+
registry_path = Path(__file__).parent.parent / "src" / "senselab" / "model_registry.yaml"
1212
with open(registry_path) as f:
1313
models = yaml.safe_load(f)
1414

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
11
""".. include:: ./doc.md""" # noqa: D415
22

33
from .api import classify_audios, scene_results_to_segments # noqa: F401
4+
from .speech_emotion_recognition import classify_emotions_from_speech # noqa: F401
5+
from .yamnet import YAMNetClassifier # noqa: F401
6+
7+
__all__ = ["classify_audios", "classify_emotions_from_speech", "scene_results_to_segments", "YAMNetClassifier"]

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

Lines changed: 46 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,36 @@
11
"""This module represents the API for the speech classification task within the senselab package.
22
3-
Currently, it supports only Hugging Face models, with plans to include more in the future.
4-
Users can specify the audio clips to classify, the classification model, the preferred device,
5-
and the model-specific parameters, and senselab handles the rest.
3+
Supports HuggingFace models and YAMNet (via isolated subprocess venv).
4+
Users can specify the audio clips to classify, the classification model,
5+
the preferred device, and the model-specific parameters.
66
77
When ``win_length`` is provided, classification runs over sliding windows,
88
producing per-window results with timestamps. If omitted, the entire audio
9-
is classified as a single unit.
9+
is classified as a single unit (HF models) or using the model's own
10+
internal windowing (YAMNet).
1011
"""
1112

1213
from typing import Any, Dict, List, Optional, Union
1314

1415
from senselab.audio.data_structures import Audio, AudioClassificationResult
1516
from senselab.audio.tasks.classification.huggingface import HuggingFaceAudioClassifier
1617
from senselab.utils.compatibility import requires_compatibility
17-
from senselab.utils.data_structures import DeviceType, HFModel, SenselabModel
18+
from senselab.utils.data_structures import DeviceType, HFModel, SenselabModel, logger
19+
20+
_YAMNET_ALIASES = {"yamnet", "google/yamnet"}
21+
22+
23+
def _is_yamnet(model: Union[SenselabModel, str]) -> bool:
24+
"""Check if the model refers to YAMNet."""
25+
if isinstance(model, str):
26+
return model.lower() in _YAMNET_ALIASES
27+
return False
1828

1929

2030
@requires_compatibility("audio.tasks.classification.classify_audios")
2131
def classify_audios(
2232
audios: List[Audio],
23-
model: SenselabModel,
33+
model: Union[SenselabModel, str],
2434
device: Optional[DeviceType] = None,
2535
win_length: Optional[float] = None,
2636
hop_length: Optional[float] = None,
@@ -29,8 +39,9 @@ def classify_audios(
2939
) -> Union[List[AudioClassificationResult], List[List[Dict[str, Any]]]]:
3040
"""Classify audios using the given model.
3141
32-
When ``win_length`` is ``None`` (default), each audio is classified as a
33-
whole and the return type is ``List[AudioClassificationResult]``.
42+
When ``win_length`` is ``None`` (default) and the model is an HF model,
43+
each audio is classified as a whole and the return type is
44+
``List[AudioClassificationResult]``.
3445
3546
When ``win_length`` is provided (in seconds), each audio is sliced into
3647
overlapping windows and classified per-window. The return type changes
@@ -42,23 +53,39 @@ def classify_audios(
4253
- ``win_length`` / ``hop_length`` (float): the parameters used
4354
(captured for provenance).
4455
56+
**YAMNet** (``model="yamnet"``): Runs in an isolated TensorFlow
57+
subprocess venv. YAMNet uses fixed 0.96 s windows with 0.48 s hop
58+
internally, so it always returns windowed results. The ``win_length``
59+
and ``hop_length`` parameters are ignored for YAMNet.
60+
4561
Args:
4662
audios: Audio objects to classify.
47-
model: The classification model.
48-
device: Device for inference (default: auto-select).
63+
model: The classification model. Can be an ``HFModel`` for
64+
HuggingFace pipelines, or ``"yamnet"`` for the YAMNet
65+
subprocess backend.
66+
device: Device for inference (default: auto-select). Ignored
67+
for YAMNet (TensorFlow manages devices internally).
4968
win_length: Window duration in seconds. If ``None``, classify
5069
the full audio. If set, ``hop_length`` defaults to
51-
``win_length / 2`` when not provided.
70+
``win_length / 2`` when not provided. Ignored for YAMNet.
5271
hop_length: Hop (step) duration in seconds for windowed mode.
53-
Ignored when ``win_length`` is ``None``.
72+
Ignored when ``win_length`` is ``None``. Ignored for YAMNet.
5473
top_k: Keep only the top-k labels per result. Applies in both
55-
whole-audio and windowed modes. ``None`` keeps all labels.
74+
whole-audio and windowed modes. ``None`` keeps all labels
75+
(defaults to 5 for windowed mode and YAMNet).
5676
**kwargs: Forwarded to the backend classifier.
5777
5878
Returns:
59-
``List[AudioClassificationResult]`` in whole-audio mode, or
60-
``List[List[Dict]]`` in windowed mode.
79+
``List[AudioClassificationResult]`` in whole-audio mode (HF only), or
80+
``List[List[Dict]]`` in windowed mode or when using YAMNet.
6181
"""
82+
if _is_yamnet(model):
83+
from senselab.audio.tasks.classification.yamnet import YAMNetClassifier
84+
85+
if win_length is not None:
86+
logger.info("YAMNet uses fixed 0.96s windows; win_length/hop_length parameters are ignored.")
87+
return YAMNetClassifier.classify_with_yamnet(audios=audios, top_k=top_k or 5)
88+
6289
if win_length is not None:
6390
return _classify_windowed(
6491
audios=audios,
@@ -79,7 +106,7 @@ def classify_audios(
79106

80107
def _classify_whole(
81108
audios: List[Audio],
82-
model: SenselabModel,
109+
model: Union[SenselabModel, str],
83110
device: Optional[DeviceType] = None,
84111
**kwargs: Any, # noqa: ANN401
85112
) -> List[AudioClassificationResult]:
@@ -89,13 +116,14 @@ def _classify_whole(
89116
audios=audios, model=model, device=device, **kwargs
90117
)
91118
raise NotImplementedError(
92-
"Only Hugging Face models are supported for now. We aim to support more models in the future."
119+
f"Model type {type(model).__name__} is not supported for whole-audio classification. "
120+
"Use HFModel for HuggingFace pipelines or 'yamnet' for YAMNet."
93121
)
94122

95123

96124
def _classify_windowed(
97125
audios: List[Audio],
98-
model: SenselabModel,
126+
model: Union[SenselabModel, str],
99127
device: Optional[DeviceType],
100128
win_length: float,
101129
hop_length: float,

src/senselab/audio/tasks/classification/doc.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,18 @@ Audio Classification refers to the broad category of tasks of processing audio i
99
A variety of models are supported by ```senselab``` for audio classification tasks. They can be explored on the [Hugging Face Hub](https://huggingface.co/models?library=transformers&pipeline_tag=audio-classification&sort=downloads). Each model varies in performance, size, license, language support, and more. Like with many models and tasks, performance may also vary depending on who the speaker is in the processed audio clips (there may be differences in terms of age, dialects, disfluencies). It is recommended to review the model card for each model before use. Also, always refer to the most recent literature for an informed decision. Unlike other tasks, the exact classification task will be based off of the dataset and class labels used to train the model, such that even two models using the same dataset might classify the audios into different categories (e.g. the LibriSpeech dataset could be used to classify age and/or gender based on the audio clips).
1010

1111
Some popular models for different classifications include:
12+
13+
**Auditory Scene / Sound Event Classification:**
14+
- [Audio Spectrogram Transformer (AST)](https://huggingface.co/MIT/ast-finetuned-audioset-10-10-0.4593) — 521 AudioSet classes (speech, music, environmental sounds, animals, etc.). Recommended for general-purpose scene analysis.
15+
- [YAMNet](https://tfhub.dev/google/yamnet/1) — Google's AudioSet classifier (521 classes). TensorFlow-based; runs in an isolated subprocess venv via `classify_audios(audios, model="yamnet")`.
16+
17+
**Speaker / Demographics:**
1218
- [Age](https://huggingface.co/audeering/wav2vec2-large-robust-24-ft-age-gender)
1319
- [Gender](https://huggingface.co/alefiury/wav2vec2-large-xlsr-53-gender-recognition-librispeech)
14-
- [Music Genre](https://huggingface.co/agercas/distilhubert-finetuned-gtzan/blob/main/config.json)
1520
- [Adult vs. Child Speech](https://huggingface.co/bookbot/wav2vec2-adult-child-cls)
21+
22+
**Content:**
23+
- [Music Genre](https://huggingface.co/agercas/distilhubert-finetuned-gtzan)
1624
- [Emotion Recognition](https://huggingface.co/ehcalabres/wav2vec2-lg-xlsr-en-speech-emotion-recognition)
1725

1826
## Evaluation

src/senselab/audio/tasks/classification/huggingface.py

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
from transformers import Pipeline, pipeline
1212

1313
from senselab.audio.data_structures import Audio, AudioClassificationResult
14+
from senselab.audio.tasks.preprocessing import resample_audios
1415
from senselab.utils.data_structures import DeviceType, HFModel, _select_device_and_dtype
1516
from senselab.utils.data_structures.logging import logger
1617

@@ -131,17 +132,15 @@ def _audio_to_huggingface_dict(audio: Audio) -> Dict:
131132
if expected_sampling_rate is None:
132133
raise ValueError("Internal error: The Hugging Face model does not specify an expected sampling rate.")
133134

134-
# Check that all audio objects are mono
135+
# Validate mono and resample to expected rate inside the loop
136+
# to avoid holding all resampled audios in memory at once
137+
formatted_audios = []
135138
for audio in audios:
136139
if audio.waveform.shape[0] != 1:
137140
raise ValueError(f"Stereo audio is not supported. Got {audio.waveform.shape[0]} channels")
138141
if audio.sampling_rate != expected_sampling_rate:
139-
raise ValueError(
140-
f"Incorrect sampling rate. Expected {expected_sampling_rate}, got {audio.sampling_rate}"
141-
)
142-
143-
# Convert the audio objects to dictionaries that can be used by the pipeline
144-
formatted_audios = [_audio_to_huggingface_dict(audio) for audio in audios]
142+
audio = resample_audios([audio], resample_rate=expected_sampling_rate)[0]
143+
formatted_audios.append(_audio_to_huggingface_dict(audio))
145144

146145
# Take the start time of the transcription
147146
start_time_transcription = time.time()
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
""".. include:: ./doc.md""" # noqa: D415
22

33
from .api import classify_emotions_from_speech # noqa: F401
4+
5+
__all__ = ["classify_emotions_from_speech"]

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -210,7 +210,7 @@ def _get_ser_type(model: HFModel) -> SERType:
210210
# Fall back to raw config dict for models with invalid fields
211211
from huggingface_hub import hf_hub_download
212212

213-
config_path = hf_hub_download(model.path_or_uri, "config.json", revision=model.revision)
213+
config_path = hf_hub_download(str(model.path_or_uri), "config.json", revision=model.revision)
214214
with open(config_path) as f:
215215
config_dict = json.load(f)
216216
config = SimpleNamespace(id2label=config_dict.get("id2label", {}))
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""YAMNet audio classification via isolated subprocess venv.
2+
3+
YAMNet is a TensorFlow-based model that classifies audio into 521
4+
AudioSet classes. It runs in an isolated subprocess venv to avoid
5+
TF/PyTorch conflicts.
6+
"""
7+
8+
import json
9+
import subprocess
10+
import tempfile
11+
from pathlib import Path
12+
from typing import Any, Dict, List
13+
14+
from senselab.audio.data_structures import Audio
15+
from senselab.audio.tasks.preprocessing import resample_audios
16+
from senselab.utils.subprocess_venv import _clean_subprocess_env, ensure_venv, parse_subprocess_result
17+
18+
_YAMNET_VENV = "yamnet"
19+
_YAMNET_REQUIREMENTS = [
20+
"tensorflow",
21+
"tensorflow-hub",
22+
"setuptools<70", # tensorflow-hub needs pkg_resources
23+
"numpy",
24+
"soundfile",
25+
]
26+
_YAMNET_PYTHON = "3.12"
27+
28+
_YAMNET_WORKER = r"""
29+
import json
30+
import sys
31+
32+
try:
33+
import numpy as np
34+
import soundfile as sf
35+
import tensorflow_hub as hub
36+
37+
args = json.loads(sys.stdin.read())
38+
audio_paths = args["audio_paths"]
39+
top_k = args.get("top_k", 5)
40+
41+
# Load YAMNet
42+
model = hub.load("https://tfhub.dev/google/yamnet/1")
43+
44+
# Load class names from the model's assets
45+
import csv
46+
class_map_path = model.class_map_path().numpy().decode("utf-8")
47+
with open(class_map_path) as f:
48+
reader = csv.DictReader(f)
49+
class_names = [row["display_name"] for row in reader]
50+
51+
all_results = []
52+
for audio_path in audio_paths:
53+
# Audio is already resampled to 16kHz mono by the caller
54+
data, sr = sf.read(audio_path, dtype="float32")
55+
if data.ndim > 1:
56+
data = data.mean(axis=1)
57+
58+
scores, embeddings, spectrogram = model(data)
59+
scores_np = scores.numpy()
60+
61+
# Each row in scores is a ~0.96s window
62+
windows = []
63+
for i, frame_scores in enumerate(scores_np):
64+
top_indices = frame_scores.argsort()[::-1][:top_k]
65+
windows.append({
66+
"labels": [class_names[idx] for idx in top_indices],
67+
"scores": [float(frame_scores[idx]) for idx in top_indices],
68+
})
69+
all_results.append(windows)
70+
71+
print(json.dumps({"results": all_results}))
72+
except Exception as exc:
73+
print(json.dumps({"error": {"type": type(exc).__name__, "message": str(exc)}}))
74+
sys.exit(1)
75+
"""
76+
77+
78+
class YAMNetClassifier:
79+
"""YAMNet audio classification via isolated subprocess venv."""
80+
81+
# YAMNet uses fixed 0.96s windows with 0.48s hop internally
82+
WINDOW_SECONDS = 0.96
83+
HOP_SECONDS = 0.48
84+
85+
@classmethod
86+
def classify_with_yamnet(
87+
cls,
88+
audios: List[Audio],
89+
top_k: int = 5,
90+
) -> List[List[Dict[str, Any]]]:
91+
"""Classify audios using YAMNet (521 AudioSet classes).
92+
93+
YAMNet uses its own internal windowing (0.96s windows, 0.48s hop).
94+
Each audio produces multiple per-window results.
95+
96+
Args:
97+
audios: Audio objects (mono, any sample rate — resampled to 16kHz internally).
98+
top_k: Number of top labels per window.
99+
100+
Returns:
101+
List of per-audio results, each containing per-window dicts
102+
with ``labels``, ``scores``, ``start``, ``end``.
103+
"""
104+
venv_dir = ensure_venv(_YAMNET_VENV, _YAMNET_REQUIREMENTS, python_version=_YAMNET_PYTHON)
105+
python = str(venv_dir / "bin" / "python")
106+
107+
with tempfile.TemporaryDirectory(prefix="senselab-yamnet-") as tmpdir:
108+
tmp = Path(tmpdir)
109+
110+
audio_paths = []
111+
durations = []
112+
for i, audio in enumerate(audios):
113+
# Resample to 16kHz inside the loop to avoid holding all
114+
# resampled audios in memory simultaneously
115+
resampled = resample_audios([audio], resample_rate=16000)[0]
116+
path = str(tmp / f"audio_{i}.wav")
117+
resampled.save_to_file(path)
118+
audio_paths.append(path)
119+
durations.append(resampled.waveform.shape[1] / resampled.sampling_rate)
120+
121+
input_json = json.dumps(
122+
{
123+
"audio_paths": audio_paths,
124+
"top_k": top_k,
125+
}
126+
)
127+
128+
env = _clean_subprocess_env()
129+
result = subprocess.run(
130+
[python, "-c", _YAMNET_WORKER],
131+
input=input_json,
132+
capture_output=True,
133+
text=True,
134+
timeout=600,
135+
env=env,
136+
)
137+
138+
output = parse_subprocess_result(result, "YAMNet")
139+
140+
# Add timestamps to each window based on YAMNet's fixed windowing
141+
all_results: List[List[Dict[str, Any]]] = []
142+
for audio_idx, windows in enumerate(output.get("results", [])):
143+
duration = durations[audio_idx] if audio_idx < len(durations) else 0.0
144+
timestamped = []
145+
for i, w in enumerate(windows):
146+
start = i * cls.HOP_SECONDS
147+
end = min(start + cls.WINDOW_SECONDS, duration)
148+
timestamped.append(
149+
{
150+
"start": start,
151+
"end": end,
152+
"labels": w["labels"],
153+
"scores": w["scores"],
154+
"win_length": cls.WINDOW_SECONDS,
155+
"hop_length": cls.HOP_SECONDS,
156+
}
157+
)
158+
all_results.append(timestamped)
159+
160+
return all_results
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
11
""".. include:: ./doc.md""" # noqa: D415
22

33
from .api import augment_audios # noqa: F401
4+
5+
__all__ = ["augment_audios"]

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

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,3 +9,13 @@
99
to_frame_major_posteriorgram,
1010
)
1111
from .sparc import SparcFeatureExtractor # noqa: F401
12+
13+
__all__ = [
14+
"extract_features_from_audios",
15+
"extract_mean_phoneme_durations",
16+
"extract_ppg_segments",
17+
"extract_ppgs_from_audios",
18+
"plot_ppg_phoneme_timeline",
19+
"to_frame_major_posteriorgram",
20+
"SparcFeatureExtractor",
21+
]

0 commit comments

Comments
 (0)