Skip to content

Commit 71a2a85

Browse files
satraclaude
andauthored
audio analysis script + multilingual MMS aligner + 4 new ASR backends (#510)
* Add analyze_audio.py: multi-task audio analysis with cache + LS export Single-command developer script that runs senselab's full task suite (diarization, AST, YAMNet, multi-backend features incl. squim quality metrics, ASR, speaker embeddings) on an input audio file with and without speech enhancement. Multiple models per task; AST and YAMNet each driven at their native temporal resolution. Output layout under artifacts/analyze_audio/<stem>_<timestamp>/: - summary.json + per-(pass × task × model) JSON files - labelstudio_tasks.json + labelstudio_config.xml for direct LS import (one task per audio variant; one timeline track per analyzer × model) Content-addressable cache under artifacts/analyze_audio_cache/ keyed by sha256(audio_signature, task, model, params, wrapper_hash, senselab_ver, schema_ver). Re-running with identical inputs replays prior outcomes without invoking models. Per-model failures are captured in their own JSON without aborting the run. Forced-alignment auto-pairing for timestamp-less ASR models, the multilingual aligner, NeMo Canary-Qwen, and Alibaba Qwen3-ASR backends land in follow-up commits on this branch (see specs/20260506-154425-audio-analysis-asr-extensions/tasks.md). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Add uroman to nlp extra for forced-alignment ja/zh romanization uroman (MIT, pure-Python, ~1.3.1.1) is needed by the upcoming MMS forced-alignment backend to romanize Japanese and Chinese transcripts before MMS's Roman-character tokenizer sees them. Lives in the optional [nlp] extra so default `uv sync` is unaffected. The MMS aligner imports uroman lazily, only when the language is ja or zh — users who never align in those languages don't need the extra. Verified default `uv sync` does not pull uroman; `uv sync --extra nlp` does. uv.lock updated to record the new package; existing transitive deps are unchanged. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Foundational: cache schema v2 + ASR dispatch table for new backends scripts/analyze_audio.py: - Bump _CACHE_SCHEMA_VERSION from 1 to 2. Comment documents that v2 introduces alignment as a separate cache entry from the ASR call that produced its input transcript. v1 entries are inert and can be removed. src/senselab/audio/tasks/speech_to_text/api.py: - Add three new module-level prefix tables for upcoming backends: _CANARY_PREFIXES = ("nvidia/canary-",) # NeMo SALM via subprocess venv _QWEN_ASR_PREFIXES = ("Qwen/Qwen3-ASR",) # qwen-asr wrapper via subprocess venv _TIMESTAMP_LESS_HF_MODELS = ("ibm-granite/granite-speech-",) - Add stub dispatch branches for Canary and Qwen-ASR that raise NotImplementedError pointing at the follow-up tasks (T025-T027 for Canary, T030-T032 for Qwen). Lands the routing structure now so the modules can be filled in independently without re-touching api.py. - For models matching _TIMESTAMP_LESS_HF_MODELS, default return_timestamps=False so the HF pipeline does not raise. Callers can still override explicitly. This unblocks IBM Granite Speech 3.3 through the existing huggingface.py path (the analyze_audio script then auto-aligns via the upcoming MMS forced-alignment backend). Dispatch order: NeMo (existing) -> Canary (stub) -> Qwen-ASR (stub) -> HF pipeline (default with timestamp-less knowledge). The four prefix groups are disjoint. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * Script: separable ASR/alignment cache + auto-align CLI flags + smoke tests Three-phase update to scripts/analyze_audio.py landing US1+US2+US3 script-side scope: US1 (one-command E2E): no behavior change; smoke tests in src/tests/scripts/analyze_audio_test.py exercise argparse defaults, audio_signature stability, the cache-key composition contract, the LS label-collection helper, and the serializer. 12 tests, no model loads, runs in <1 s on default install. US2 (re-run without re-computation): - Add transcript_signature(text), align_cache_key(...), and run_alignment_cached(...) — independent caching for the alignment step that mirrors run_task_cached but keys on (audio_signature, transcript_sha, language, aligner_model, aligner_params, wrapper, senselab_ver). - Failed alignments are NOT stored, so a future fix to the aligner / senselab upgrade triggers a fresh attempt; the parent ASR cache is unaffected per FR-024 / FR-025 / FR-026. US3 (hierarchical LS export): - Three-case LS-export branch for ASR: (a) ASR with native timestamps -> per-segment regions from the ASR result; (b) ASR text-only with a successful alignment outcome attached -> per-segment regions from alignment; (c) ASR text-only with no/failed alignment -> single full-audio TextArea region (graceful degradation per FR-025). - Add _asr_has_timestamps() helper used by the LS export and (eventually) by the auto-align stage to short-circuit cases where alignment is unnecessary. CLI surface (per contracts/cli.md, wired to the upcoming auto-align stage that lands with US6): - --no-align-asr (toggle off auto-align) - --aligner-model (default facebook/mms-1b-all) - --asr-language (force a specific language for the aligner) - --qwen-asr-no-timestamps (skip Qwen's bundled forced-aligner) - 'alignment' added to --skip choices. Default --asr-models updated to include ibm-granite/granite-speech-3.3-8b back into the comparison set; senselab's HF-pipeline path now defaults return_timestamps=False for that model id (committed in da04a29), and the upcoming MMS auto-align stage will add per-segment timestamps. The auto-align stage itself is not yet wired (T021 lands with the MMS backend in US6). Until then, --no-align-asr / --aligner-model / --asr-language are accepted but unused. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * forced_alignment: MMS multilingual aligner backend (1100+ languages) Adds Meta's MMS (Massively Multilingual Speech) as an opt-in aligner backend for senselab.audio.tasks.forced_alignment, covering 1100+ languages via per-language adapters on a single base model. Selected via the new ``align_transcriptions(..., aligner_model=...)`` keyword; the legacy per-language wav2vec2 path stays the default for existing callers (no behavior change for current code). constants.py: - MMS_MODEL_ID = "facebook/mms-1b-all" (CC-BY-NC 4.0 weights; override the model id for commercial-friendly alternatives). - ISO_1_TO_3 map for adapter selection (en, fr, de, es, pt, it, ja, zh, plus the 19 other languages already in DEFAULT_ALIGN_MODELS_HF, which MMS now also covers). forced_alignment.py: - _romanize_for_mms(text): lazy uroman import (optional dep, [nlp] extra) for ja/zh transcripts. Without uroman installed, raises an actionable ImportError pointing at `uv sync --extra nlp`. - _load_mms_aligner(iso3, device, cache): one-time load of the MMS base model + selected per-language adapter via processor.tokenizer .set_target_lang(iso3) + model.load_adapter(iso3). Cached by (model_id, iso3) so multiple calls on the same audio reuse weights. - align_transcriptions: new ``aligner_model`` kwarg. When set to MMS_MODEL_ID, dispatches to the MMS path; ja/zh transcripts go through _romanize_for_mms before tokenization. Uses model_type= "huggingface" internally — the wav2vec2 forward pass and CTC trellis/backtrack code is identical, so no changes to _get_prediction_matrix / _align_transcription / _assign_timestamps. Output shape unchanged: List[List[ScriptLine | None]]. Existing 15 forced_alignment tests still pass (run via uv run pytest src/tests/audio/tasks/forced_alignment_test.py). speech_to_text/huggingface.py: - _get_hf_asr_pipeline and transcribe_audios_with_transformers now accept ``return_timestamps: Union[str, bool, None]`` (was Optional[str]). HuggingFace's automatic-speech-recognition pipeline supports False to disable timestamps; the existing senselab dispatcher (api.py, committed earlier) defaults this to False for known-timestamp-less HF models like ibm-granite/granite-speech-3.3-8b. Public docstring updated to call out this case. This commit lands T016-T020 from the spec-kit task plan. Next: T021 wires the analyze_audio script's auto-align stage to call align_transcriptions(..., aligner_model=facebook/mms-1b-all) on every timestamp-less ASR output. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * analyze_audio: wire MMS auto-align stage; HF return_timestamps=False fix scripts/analyze_audio.py: - Wire the auto-align stage in run_pass(): after the ASR family runs, iterate every successful ASR ModelRun. Skip when --no-align-asr is set, when 'alignment' is in --skip, or when the ASR result already has native timestamps (Whisper-style start/end or word chunks). Otherwise call senselab.audio.tasks.forced_alignment.align_transcriptions via run_alignment_cached(), with the resolved language (from --asr-language, default "en"), the chosen aligner_model (default facebook/mms-1b-all), and an alignment-specific provenance block (transcript_sha, language, parent_asr_cache_key) per FR-026. - Imports forced_alignment.align_transcriptions and the Language / ScriptLine types from senselab.utils.data_structures. - Add _extract_transcript_text() helper that handles both ScriptLine objects (post-fresh-ASR) and the dict shape (post-cache-hit) so the auto-align step works after either kind of ASR outcome. - Update _asr_has_timestamps() to handle dict-shape ScriptLines too. - Refresh the script docstring's ASR section: Granite is now in defaults (auto-aligned), removing the older "intentionally NOT in defaults" note that the spec clarification overrode. Add a paragraph describing the auto-align stage and the --no-align-asr toggle. src/senselab/audio/tasks/speech_to_text/huggingface.py: - Fix _get_hf_asr_pipeline to OMIT return_timestamps from the kwargs passed to transformers.pipeline() when the caller asked for False. HF's AutomaticSpeechRecognitionPipeline behaves differently per model family: CTC raises on False (only "char"/"word" allowed), Whisper accepts False, and other generative seq2seq (Granite Speech) raises on True/word/segment. Omitting the kwarg lets each pipeline default sanely; omitting only happens when the caller explicitly passed False (timestamps were not asked for). Other values (str/None/True) are passed through unchanged. src/tests/audio/tasks/forced_alignment/mms_test.py: - New skipif-guarded smoke tests for the MMS aligner. Skipif checks whether facebook/mms-1b-all is in the local HF cache (~1.6 GB download otherwise; default CI install does not pull it). When available: verify (a) English alignment produces well-shaped ScriptLines via the real-speech fixture, (b) Japanese alignment exercises the uroman romanization path, (c) an unknown language code raises a clear ValueError pointing at the ISO_1_TO_3 registry. Assertions are SHAPE-ONLY — synthetic transcripts on real audio do not align meaningfully but the API contract is still verified. src/tests/audio/tasks/speech_to_text/huggingface_no_timestamps_test.py: - New skipif-guarded smoke test for return_timestamps=False. Uses facebook/wav2vec2-base-960h (already cached locally for the existing English aligner path) rather than Granite Speech (multi-GB). Verifies: when return_timestamps=False, transcribe_audios_with_ transformers returns text-only ScriptLines without raising the HF pipeline's CTC-rejects-False ValueError. Existing 11 senselab speech_to_text_test.py tests still pass; existing 15 forced_alignment_test.py tests still pass. This commit lands T021-T023. T024 (end-to-end Granite + Whisper validation through the analyze_audio script with real model downloads) is deferred to a follow-up session — needs ~16 GB Granite Speech download plus ~1.6 GB MMS download. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * speech_to_text: NVIDIA Canary-Qwen 2.5B subprocess-venv backend Adds CanaryQwenASR which loads SALM (Speech-Augmented Language Model) from nemo.collections.speechlm2.models in an isolated nemo-canary-qwen venv (kept separate from the existing nemo-diarization venv to avoid destabilizing Sortformer/Conformer-CTC paths). The chat-style prompt format using model.audio_locator_tag matches the published model card. Canary-Qwen is text-only (no native timestamps); the analyze_audio script's auto-align stage (US6) now adds per-segment timing via the multilingual MMS forced-aligner downstream when this model is used. Wires the api.py dispatch (replaces the NotImplementedError stub) and adds a shape-only smoke test that auto-skips when the venv has not been provisioned (~5 GB first-run install). Marks T025-T028 done in specs tasks.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * speech_to_text: Alibaba Qwen3-ASR subprocess-venv backend Adds QwenASR which loads Qwen3-ASR via Alibaba's qwen-asr Python wrapper (Qwen3ASRModel.from_pretrained) inside an isolated qwen-asr venv, optionally pairing with the bundled Qwen3-ForcedAligner-0.6B companion to produce per-word / per-CJK-char timestamps. Wires api.py dispatch (replaces the NotImplementedError stub) and threads --qwen-asr-no-timestamps from analyze_audio.py through to the ASR call so the script's MMS auto-align stage can take over when the bundled aligner is undesirable. Adds shape-only smoke tests that auto-skip until the qwen-asr venv has been provisioned. Marks T030-T033 done in specs tasks.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * polish: lint cleanup + mark Phase 9 tasks done - Add docstring to src/tests/scripts/__init__.py (D104). - Mark T035-T037 done in specs tasks.md after running: * full ruff check + mypy on changed paths (clean) * full senselab pytest suite excluding scripts/ (421 passed, 25 skipped, no regressions; voice_cloning_test ERROR in bulk run is a known order-dependent flake — passes in isolation, unrelated to forced_alignment / speech_to_text changes). T035 (CLAUDE.md update) is applied to the local file; CLAUDE.md is not yet under version control in this repo so the section addition lives in the working tree only — user decides whether to track it. T038 (final E2E validation against a tutorial WAV) intentionally deferred: requires the nemo-canary-qwen and qwen-asr venvs (~5-15 GB combined) and the MMS aligner (~1.6 GB) to be provisioned locally. The skipif-guarded smoke tests ride the venvs whenever the user provisions them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * e2e validation: fix bugs surfaced by full-pipeline run Validates T012/T015/T015a/T024/T029/T034/T038 end-to-end. Bugs found and fixed during the validation cycle: - canary_qwen.py worker: SALM.generate expects prompts as list[list[dict]] (a batch of conversations), not list[dict]. Fixed. Canary-Qwen now succeeds in ~70s on a 38s clip. - forced_alignment.py: senselab's Language normalizes ISO-639-1 ('en') to ISO-639-3 ('eng'). The MMS aligner now accepts either form. - script_line.py from_dict: three pre-existing latent bugs surfaced by MMS-aligner output: * d["chunks"][0] accessed without checking non-empty (IndexError) * d["timestamps"][0] accessed without checking length >= 2 (IndexError) * recursive construction of empty subsegments with text="" failing the "at least text or speaker" pydantic validator Empty subsegments are now filtered before recursive construction. - analyze_audio.py LS export: pre-existing bug where getattr(seg, "start") returned None for cache-restored dicts, silently dropping all regions on cache-hit runs. Added _seg_attr helper that handles both Pydantic objects and JSON dicts. Also unwrap align_transcriptions' List[List[ScriptLine]] to inner segment list before passing to _asr_to_ls. - mms_test: align test expectation with senselab.Language's stricter ISO validation (Language("xx") raises before reaching MMS). Validation results in artifacts/analyze_audio_e2e_validation.md (not committed). Marks T012/T015/T015a/T024/T029/T034/T038 done. The two known gaps documented in the validation note: YAMNet (corrupt local tfhub cache, environmental) and Granite Speech 3.3 (HF pipeline contract mismatch — needs a dedicated subprocess-venv backend in a follow-up). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * speech_to_text: IBM Granite Speech 3.3 in-process backend Adds GraniteSpeechASR which loads ``GraniteSpeechProcessor`` and ``AutoModelForSpeechSeq2Seq`` directly in-process — no subprocess venv needed because transformers, torch, torchaudio, and peft all live in senselab's core install (peft is required for Granite's LoRA adapter; loading without it produces gibberish). Why a dedicated backend rather than the HF ASR pipeline path: Granite uses ``GraniteSpeechProcessor(text, audio, device, **kwargs)`` and a ``GraniteSpeechFeatureExtractor`` whose ``__call__(audios, device)`` signature does not accept the ``{array, sampling_rate}`` audio dict that ``AutomaticSpeechRecognitionPipeline`` passes in. Routing through the pipeline failed at the feature-extractor boundary in earlier E2E runs. Worker subtleties surfaced by the E2E: - ``return_tensors="pt"`` is mandatory; without it Granite's processor returns ``input_ids`` / ``attention_mask`` as nested Python lists, which the underlying ``generate()`` then tries to call ``.shape`` on. - The feature extractor expects a 1-D audio tensor; senselab's ``Audio.waveform`` is shape ``[channels, T]``, so we squeeze after downmix. - bfloat16 on accelerators (CUDA + MPS), float32 on CPU. CPU bf16 runs but is ~40x slower than fp32 because PyTorch's CPU kernels lack a native bf16 SIMD path. MPS bf16 was verified against torch 2.11. Adds ``peft>=0.13`` to senselab's core deps. Wires api.py dispatch (replaces the timestamp-less HF route), drops Granite from ``_TIMESTAMP_LESS_HF_MODELS``, and adds a shape-only smoke test that auto-skips when the model weights are not in the local HF cache (~16 GB). Routed automatically by ``transcribe_audios`` when the model id starts with ``ibm-granite/granite-speech-``. Granite is text-only; the analyze_audio script's auto-align stage adds per-segment timestamps via the multilingual MMS forced-aligner downstream. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PR review: type annotations, MMS aligner cache, ruff format Addresses pre-commit failures and Gemini's review feedback on PR #510: - Test files: add ``model: HFModel`` annotations on canary_qwen_test, qwen_test, granite_test, huggingface_no_timestamps_test (mypy 1.20.1 is stricter than 1.18.x and infers Self<HFModel> when the literal HFModel(...) call is on the RHS — explicit annotation pins the type). - ``forced_alignment.align_transcriptions``: hoist the processor/model cache from a per-call local dict to a module-level ``_ALIGNER_CACHE``. The 1B-parameter MMS base weights (and any per-language wav2vec2 variants) now load once per process instead of once per call — fixes a perf cliff Gemini flagged in the review. - ruff format: pick up the auto-applied formatting in scripts/generate_model_registry.py + scripts/profile_model_tiers.py (no functional changes; ruff 0.15.11 vs older local), plus a small re-flow in script_line.py from_dict and granite.py. Did NOT apply Gemini's two other suggestions: - Drop ``device=`` arg in Granite ``processor(...)`` call: verified Granite's ``GraniteSpeechProcessor.__call__`` accepts ``device`` explicitly in its signature; works on MPS in run 15. - Drop ``input_len`` slicing on generate output: empirically verified via standalone MPS run that with slicing the transcript decodes correctly (``"so once upon a time there were four little rabbits..."``); without slicing it would prepend the prompt. Granite is an LLM-style multimodal generator, not a true seq2seq. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * analyze_audio: per-window features (1s/0.5s default), parquet output Replaces the single global summary that the script was emitting for opensmile / parselmouth / torchaudio-squim with proper time-series output. The rest of the analysis (diarization, AST, YAMNet, ASR, alignment, embeddings) is already temporal — features should be too. Routing: - opensmile uses ``LowLevelDescriptors`` (native ~10 ms frame grid). Run once; one parquet row per opensmile frame. - parselmouth + torchaudio-squim are summary statistics by design; the senselab wrapper only exposes the aggregate form, so these run in an external sliding window driven by the new ``--features-win-length`` (default 1.0 s) and ``--features-hop-length`` (default 0.5 s) flags. One parquet row per window. Each backend writes its own parquet sidecar (``<pass>/features/<backend>.parquet``) — different columns and different time grids don't share a schema; per-backend files keep each grid clean and let downstream readers join on ``start``/``end`` if they want a unified view. The cache outcome metadata stays in JSON for inspection parity with the other tasks (``features.json`` now points to the parquet directory). Verified end-to-end on a 38.4 s clip (run 16, both passes): - opensmile.parquet: (3837, 27), 668 KB, 0–38.411 s - parselmouth.parquet: (75, 43), 49 KB, 0–38 s - torchaudio_squim.parquet: (75, 6), 6.5 KB, 0–38 s Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> * PR review: pre-commit fixes (codespell + pretty-format-toml) - codespell: add ``vie`` (Vietnamese ISO-639-3 code in ISO_1_TO_3 registry, not the English noun) to ignore-words-list. - pretty-format-toml: re-flow [tool.codespell] block so the ``vie`` addition lands on a properly-formatted line. No code changes; all green pre-commit locally. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 6faab49 commit 71a2a85

21 files changed

Lines changed: 3215 additions & 55 deletions

File tree

pyproject.toml

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ dependencies = [
3939
"transformers>=5.0", # >=5.0 required for huggingface-hub>=1.0 compatibility
4040
"datasets>=3.6",
4141
"accelerate",
42+
"peft>=0.13", # required by Granite Speech 3.3's LoRA adapter
4243
"speechbrain>=1.0",
4344
# Audio processing backends (pyannote pulls matplotlib, torch-audiomentations)
4445
"pyannote-audio>=4.0",
@@ -59,7 +60,8 @@ dependencies = [
5960
[project.optional-dependencies]
6061
nlp = [
6162
"jiwer>=3.0",
62-
"nltk>=3.9"
63+
"nltk>=3.9",
64+
"uroman>=1.3"
6365
]
6466
text = [
6567
"sentence-transformers>=5.1",
@@ -196,4 +198,4 @@ skip = [
196198
"*.cha",
197199
"*.ipynb"
198200
]
199-
ignore-words-list = ["senselab", "nd", "astroid", "wil", "SER", "te", "EXPRESSO", "VAI"]
201+
ignore-words-list = ["senselab", "nd", "astroid", "wil", "SER", "te", "EXPRESSO", "VAI", "vie"]

scripts/analyze_audio.py

Lines changed: 1544 additions & 0 deletions
Large diffs are not rendered by default.

scripts/generate_model_registry.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ def main() -> None:
3131
for m in task_models:
3232
name = m["name"]
3333
source = m["source"]
34-
model_id = f'`{m["model_id"]}`'
34+
model_id = f"`{m['model_id']}`"
3535
emb = m.get("embedding_dim", "—")
3636
params = m.get("parameters", "—")
3737
rec = m.get("recommended_for", "—")

scripts/profile_model_tiers.py

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,7 @@ def _load_mono_16k():
137137
from senselab.audio.tasks.preprocessing import resample_audios
138138

139139
mono_path = (
140-
Path(__file__).resolve().parent.parent
141-
/ "src"
142-
/ "tests"
143-
/ "data_for_testing"
144-
/ "audio_48khz_mono_16bits.wav"
140+
Path(__file__).resolve().parent.parent / "src" / "tests" / "data_for_testing" / "audio_48khz_mono_16bits.wav"
145141
)
146142
audio = Audio(filepath=str(mono_path))
147143
return resample_audios([audio], 16000)[0]
@@ -259,11 +255,7 @@ def setup_wav2vec2_emotion_dim(device: str):
259255
from senselab.utils.data_structures import DeviceType, HFModel
260256

261257
mono_path = (
262-
Path(__file__).resolve().parent.parent
263-
/ "src"
264-
/ "tests"
265-
/ "data_for_testing"
266-
/ "audio_48khz_mono_16bits.wav"
258+
Path(__file__).resolve().parent.parent / "src" / "tests" / "data_for_testing" / "audio_48khz_mono_16bits.wav"
267259
)
268260
audio = Audio(filepath=str(mono_path))
269261
resampled = resample_audios([audio], 16000)
@@ -290,11 +282,7 @@ def setup_wav2vec2_xlsr_emotion(device: str):
290282
from senselab.utils.data_structures import DeviceType, HFModel
291283

292284
mono_path = (
293-
Path(__file__).resolve().parent.parent
294-
/ "src"
295-
/ "tests"
296-
/ "data_for_testing"
297-
/ "audio_48khz_mono_16bits.wav"
285+
Path(__file__).resolve().parent.parent / "src" / "tests" / "data_for_testing" / "audio_48khz_mono_16bits.wav"
298286
)
299287
audio = Audio(filepath=str(mono_path))
300288
resampled = resample_audios([audio], 16000)
@@ -532,8 +520,7 @@ def print_summary_table(results: List[ProfileResult]) -> None:
532520
"""Print a human-readable summary table."""
533521
print("\n" + "=" * 120)
534522
print(
535-
f"{'Model':<55} {'Task':<22} {'Device':<6} {'Cold(s)':<8} {'Warm(s)':<8} "
536-
f"{'RAM(MB)':<9} {'GPU(MB)':<9} {'Tier'}"
523+
f"{'Model':<55} {'Task':<22} {'Device':<6} {'Cold(s)':<8} {'Warm(s)':<8} {'RAM(MB)':<9} {'GPU(MB)':<9} {'Tier'}"
537524
)
538525
print("-" * 120)
539526

@@ -693,9 +680,7 @@ def pytest_sessionfinish(self, session, exitstatus):
693680

694681
if torch.cuda.is_available():
695682
report["system"]["gpu_name"] = torch.cuda.get_device_name(0)
696-
report["system"]["gpu_memory_gb"] = round(
697-
torch.cuda.get_device_properties(0).total_mem / (1024**3), 1
698-
)
683+
report["system"]["gpu_memory_gb"] = round(torch.cuda.get_device_properties(0).total_mem / (1024**3), 1)
699684

700685
# Summary stats
701686
times = [r["wall_seconds"] for r in self.results]

specs/20260506-154425-audio-analysis-asr-extensions/tasks.md

Lines changed: 251 additions & 0 deletions
Large diffs are not rendered by default.

src/senselab/audio/tasks/forced_alignment/constants.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,54 @@
88

99
LANGUAGES_WITHOUT_SPACES = ["ja", "zh"]
1010

11+
# MMS (Massively Multilingual Speech) — single-model fallback aligner
12+
# covering 1100+ languages via per-language adapters. Selected via the
13+
# ``align_transcriptions(..., aligner_model=MMS_MODEL_ID)`` knob; not the
14+
# default for the languages already in DEFAULT_ALIGN_MODELS_HF /
15+
# DEFAULT_ALIGN_MODELS_TORCH (preserves backwards-compat for existing
16+
# callers, who keep getting the same per-language wav2vec2 models).
17+
#
18+
# Weights are CC-BY-NC 4.0 (research / non-commercial). Override via
19+
# the public API for commercial-friendly alternatives.
20+
MMS_MODEL_ID = "facebook/mms-1b-all"
21+
22+
# ISO 639-1 -> ISO 639-3 map for MMS adapter selection. MMS adapters are
23+
# keyed by ISO-3 codes; senselab callers (and Language objects) typically
24+
# carry ISO-1. Languages absent from this map cannot be auto-resolved to
25+
# an MMS adapter and will raise an actionable error.
26+
ISO_1_TO_3 = {
27+
"en": "eng",
28+
"fr": "fra",
29+
"de": "deu",
30+
"es": "spa",
31+
"it": "ita",
32+
"pt": "por",
33+
"ja": "jpn",
34+
"zh": "cmn", # Mandarin Chinese
35+
"nl": "nld",
36+
"uk": "ukr",
37+
"ar": "ara",
38+
"cs": "ces",
39+
"ru": "rus",
40+
"pl": "pol",
41+
"hu": "hun",
42+
"fi": "fin",
43+
"fa": "fas",
44+
"el": "ell",
45+
"tr": "tur",
46+
"da": "dan",
47+
"he": "heb",
48+
"vi": "vie",
49+
"ko": "kor",
50+
"ur": "urd",
51+
"te": "tel",
52+
"hi": "hin",
53+
"ca": "cat",
54+
"ml": "mal",
55+
"no": "nor",
56+
"nn": "nno",
57+
}
58+
1159
DEFAULT_ALIGN_MODELS_TORCH = {
1260
"fr": {"path_or_uri": "VOXPOPULI_ASR_BASE_10K_FR", "revision": "main"},
1361
"de": {"path_or_uri": "VOXPOPULI_ASR_BASE_10K_DE", "revision": "main"},

src/senselab/audio/tasks/forced_alignment/forced_alignment.py

Lines changed: 113 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -10,8 +10,10 @@
1010
from senselab.audio.data_structures import Audio
1111
from senselab.audio.tasks.forced_alignment.constants import (
1212
DEFAULT_ALIGN_MODELS_HF,
13+
ISO_1_TO_3,
1314
LANGUAGES_WITHOUT_SPACES,
1415
MINIMUM_SEGMENT_SIZE,
16+
MMS_MODEL_ID,
1517
PUNKT_ABBREVIATIONS,
1618
SAMPLE_RATE,
1719
)
@@ -566,9 +568,72 @@ def filter_aligned_script_lines(
566568
return [flatten_script_lines(scriptline_list) for scriptline_list in aligned_script_lines]
567569

568570

571+
def _romanize_for_mms(text: str) -> str:
572+
"""Romanize a transcript so MMS's Roman-script adapter can tokenize it.
573+
574+
MMS uses Roman characters internally; for languages written in non-Roman
575+
scripts (notably Japanese kanji/kana and Chinese Hanzi) the transcript
576+
must be uroman-romanized before alignment. ``uroman`` is an optional
577+
dependency in the ``[nlp]`` extra and is imported lazily so default
578+
senselab installs are not affected.
579+
580+
Raises ``ImportError`` with an actionable hint when uroman is absent.
581+
"""
582+
try:
583+
from uroman import Uroman # type: ignore[import-not-found]
584+
except ImportError as exc:
585+
raise ImportError(
586+
"uroman is required to align ja/zh transcripts via MMS. "
587+
"Install with `uv sync --extra nlp` (adds uroman to the venv) "
588+
"or pass an aligner that does not require romanization."
589+
) from exc
590+
uroman = Uroman()
591+
return str(uroman.romanize_string(text))
592+
593+
594+
def _load_mms_aligner(
595+
iso3: str,
596+
device: DeviceType,
597+
cache: Dict[Any, Any],
598+
) -> Tuple[Any, Any]:
599+
"""Load (or fetch from cache) the MMS Wav2Vec2 model + processor for one language adapter.
600+
601+
MMS-1B-All is a single base model with one set of weights and 1100+
602+
swappable per-language adapters. We cache by (model_id, iso3) so two
603+
languages on the same audio reuse the base weights but swap adapters.
604+
605+
Return types are intentionally ``Any`` because the transformers type
606+
stubs do not expose ``Wav2Vec2Processor.tokenizer`` (PreTrainedTokenizer
607+
on a multi-modal processor is duck-typed), and over-annotating triggers
608+
spurious mypy errors. Runtime types are
609+
``(Wav2Vec2Processor, Wav2Vec2ForCTC)`` as documented.
610+
"""
611+
key = (MMS_MODEL_ID, iso3)
612+
if key not in cache:
613+
processor = Wav2Vec2Processor.from_pretrained(MMS_MODEL_ID)
614+
processor.tokenizer.set_target_lang(iso3) # type: ignore[attr-defined]
615+
model = Wav2Vec2ForCTC.from_pretrained(
616+
MMS_MODEL_ID,
617+
target_lang=iso3,
618+
ignore_mismatched_sizes=True,
619+
).to(device.value) # type: ignore[arg-type]
620+
model.load_adapter(iso3)
621+
cache[key] = (processor, model)
622+
return cache[key]
623+
624+
625+
# Module-level processor/model cache. Holds either ``(model_id, iso3) →
626+
# (processor, model)`` for the MMS path or ``model_id → (processor,
627+
# model)`` for the per-language wav2vec2 dispatch. Persists across
628+
# ``align_transcriptions`` calls so the 1B-parameter MMS weights load
629+
# once per process, not once per pass / audio.
630+
_ALIGNER_CACHE: Dict[Any, Any] = {}
631+
632+
569633
def align_transcriptions(
570634
audios_and_transcriptions_and_language: List[Tuple[Audio, ScriptLine, Language]],
571635
levels_to_keep: Dict = {"utterance": False, "word": False, "char": False},
636+
aligner_model: Optional[str] = None,
572637
) -> List[List[ScriptLine | None]]:
573638
"""Align multiple transcriptions with their respective audios using a wav2vec2.0 model.
574639
@@ -577,36 +642,70 @@ def align_transcriptions(
577642
Each tuple contains an Audio object, a ScriptLine with transcription,
578643
and an optional Language (default is English).
579644
levels_to_keep (Dict): Levels of transcription to keep in output.
645+
aligner_model (str | None, optional):
646+
Override the default per-language wav2vec2 aligner. When set to
647+
``MMS_MODEL_ID`` (``facebook/mms-1b-all``), the multilingual MMS
648+
backend is used: a single model with per-language adapters
649+
covering 1100+ languages. Adapter selection is driven by the
650+
language code; ja/zh transcripts are romanized via uroman
651+
(optional dep, ``[nlp]`` extra) before tokenization. When
652+
``None`` (default), the per-language wav2vec2 dispatch table
653+
``DEFAULT_ALIGN_MODELS_HF`` is used as before.
580654
581655
Returns:
582656
List[List[ScriptLine | None]]: A list of aligned results for each audio.
583657
"""
584658
aligned_script_lines: list[list[ScriptLine | None]] = []
585-
loaded_processors_and_models = {}
659+
# Module-level cache: each call into align_transcriptions reuses the
660+
# 1B-parameter MMS base weights (and any wav2vec2 variants) from a
661+
# prior call rather than reloading from disk every time.
662+
loaded_processors_and_models: Dict[Any, Any] = _ALIGNER_CACHE
586663
device = _select_device_and_dtype()[0]
664+
use_mms = aligner_model == MMS_MODEL_ID
587665

588666
for recording in audios_and_transcriptions_and_language:
589667
audio, transcription, language = (*recording, None)[:3]
590668
if language is None:
591669
language = Language(language_code="en")
592670

593-
model_dict = DEFAULT_ALIGN_MODELS_HF.get(language.language_code, DEFAULT_ALIGN_MODELS_HF["en"])
594-
model_variant: HFModel = HFModel(path_or_uri=model_dict["path_or_uri"], revision=model_dict["revision"])
595-
if model_variant.path_or_uri not in loaded_processors_and_models:
596-
processor = Wav2Vec2Processor.from_pretrained(model_variant.path_or_uri, revision=model_variant.revision)
597-
model = Wav2Vec2ForCTC.from_pretrained(model_variant.path_or_uri, revision=model_variant.revision).to(
598-
device.value
599-
)
600-
loaded_processors_and_models[model_variant.path_or_uri] = (processor, model)
601-
602-
processor, model = loaded_processors_and_models[model_variant.path_or_uri]
671+
if use_mms:
672+
code = language.language_code
673+
# senselab's Language normalizes ISO-639-1 ("en") to ISO-639-3 ("eng").
674+
# Accept either form: pass through 3-letter codes; map 2-letter codes
675+
# via ISO_1_TO_3.
676+
if len(code) == 3:
677+
iso3: Optional[str] = code
678+
else:
679+
iso3 = ISO_1_TO_3.get(code)
680+
if iso3 is None:
681+
raise ValueError(
682+
f"MMS aligner has no ISO-639-3 mapping for language "
683+
f"{code!r}. Add it to ISO_1_TO_3 in "
684+
f"senselab.audio.tasks.forced_alignment.constants, or pass "
685+
f"a different aligner_model."
686+
)
687+
processor, model = _load_mms_aligner(iso3, device, loaded_processors_and_models)
688+
else:
689+
model_dict = DEFAULT_ALIGN_MODELS_HF.get(language.language_code, DEFAULT_ALIGN_MODELS_HF["en"])
690+
model_variant: HFModel = HFModel(path_or_uri=model_dict["path_or_uri"], revision=model_dict["revision"])
691+
if model_variant.path_or_uri not in loaded_processors_and_models:
692+
processor = Wav2Vec2Processor.from_pretrained(
693+
model_variant.path_or_uri, revision=model_variant.revision
694+
)
695+
_w2v = Wav2Vec2ForCTC.from_pretrained(model_variant.path_or_uri, revision=model_variant.revision)
696+
model = _w2v.to(device.value) # type: ignore[arg-type]
697+
loaded_processors_and_models[model_variant.path_or_uri] = (processor, model)
698+
processor, model = loaded_processors_and_models[model_variant.path_or_uri]
603699

604700
if audio.sampling_rate != SAMPLE_RATE:
605701
raise ValueError(f"{audio.sampling_rate} rate is not equal to {SAMPLE_RATE}.")
606702

607703
start = transcription.start if transcription.start is not None else 0.0
608704
end = transcription.end if transcription.end is not None else audio.waveform.shape[1] / audio.sampling_rate
609705
text = transcription.text if transcription.text is not None else ""
706+
# MMS uses Roman characters; ja/zh transcripts must be romanized first.
707+
if use_mms and language.language_code in LANGUAGES_WITHOUT_SPACES:
708+
text = _romanize_for_mms(text)
610709

611710
segments = [
612711
SingleSegment(
@@ -621,7 +720,9 @@ def align_transcriptions(
621720
align_model_metadata={
622721
"dictionary": processor.tokenizer.get_vocab(),
623722
"language": language,
624-
"type": "huggingface" if isinstance(model_variant, HFModel) else "torchaudio",
723+
# MMS shares the wav2vec2 forward pass with the per-language
724+
# HF aligners, so the trellis/backtrack code is identical.
725+
"type": "huggingface",
625726
},
626727
audio=audio,
627728
device=device,

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

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,41 @@
88
from typing import Any, List, Optional
99

1010
from senselab.audio.data_structures import Audio
11+
from senselab.audio.tasks.speech_to_text.canary_qwen import CanaryQwenASR
12+
from senselab.audio.tasks.speech_to_text.granite import GraniteSpeechASR
1113
from senselab.audio.tasks.speech_to_text.huggingface import HuggingFaceASR
1214
from senselab.audio.tasks.speech_to_text.nemo import NeMoASR
15+
from senselab.audio.tasks.speech_to_text.qwen import QwenASR
1316
from senselab.utils.compatibility import requires_compatibility
1417
from senselab.utils.data_structures import DeviceType, HFModel, Language, ScriptLine, SenselabModel
1518

1619
# NeMo model ID prefixes — route to NeMo backend when detected
1720
_NEMO_PREFIXES = ("nvidia/stt_", "nvidia/conformer")
1821

22+
# NVIDIA Canary-Qwen prefix — route to a separate NeMo subprocess venv
23+
# (canary_qwen.py) that loads SALM from nemo.collections.speechlm2.models.
24+
# The implementation lives in a follow-up commit; this constant is used by
25+
# the dispatch table below so the routing structure is settled.
26+
_CANARY_PREFIXES = ("nvidia/canary-",)
27+
28+
# Alibaba Qwen3-ASR prefix — route to a separate Qwen subprocess venv
29+
# (qwen.py) that uses Alibaba's qwen-asr Python wrapper plus the optional
30+
# Qwen3-ForcedAligner companion for word-level timestamps.
31+
_QWEN_ASR_PREFIXES = ("Qwen/Qwen3-ASR",)
32+
33+
# IBM Granite Speech prefix — route to a separate granite subprocess venv
34+
# (granite.py). Granite uses GraniteSpeechProcessor's (text, audio, device)
35+
# signature, not the standard HF ASR pipeline contract, so it cannot be
36+
# driven through HuggingFaceASR. Text-only output; auto-aligned downstream.
37+
_GRANITE_PREFIXES = ("ibm-granite/granite-speech-",)
38+
39+
# HuggingFace ASR models that are known to NOT produce native timestamps —
40+
# their HF pipelines reject return_timestamps. For these we default to
41+
# return_timestamps=False so the pipeline returns text-only ScriptLines;
42+
# downstream code (e.g., scripts/analyze_audio.py) can post-align via
43+
# senselab.audio.tasks.forced_alignment to add per-segment timestamps.
44+
_TIMESTAMP_LESS_HF_MODELS: tuple[str, ...] = ()
45+
1946

2047
@requires_compatibility("audio.tasks.speech_to_text.transcribe_audios")
2148
def transcribe_audios(
@@ -98,7 +125,23 @@ def transcribe_audios(
98125
try:
99126
if isinstance(model, HFModel) and str(model.path_or_uri).startswith(_NEMO_PREFIXES):
100127
return NeMoASR.transcribe_with_nemo(audios=audios, model=model, device=device)
128+
elif isinstance(model, HFModel) and str(model.path_or_uri).startswith(_CANARY_PREFIXES):
129+
return CanaryQwenASR.transcribe_with_canary_qwen(audios=audios, model=model, device=device)
130+
elif isinstance(model, HFModel) and str(model.path_or_uri).startswith(_QWEN_ASR_PREFIXES):
131+
qwen_kwargs: dict[str, Any] = {}
132+
if "return_timestamps" in kwargs:
133+
qwen_kwargs["return_timestamps"] = bool(kwargs.pop("return_timestamps"))
134+
if "forced_aligner" in kwargs:
135+
qwen_kwargs["forced_aligner"] = kwargs.pop("forced_aligner")
136+
return QwenASR.transcribe_with_qwen(audios=audios, model=model, device=device, **qwen_kwargs)
137+
elif isinstance(model, HFModel) and str(model.path_or_uri).startswith(_GRANITE_PREFIXES):
138+
return GraniteSpeechASR.transcribe_with_granite(audios=audios, model=model, device=device)
101139
elif isinstance(model, HFModel):
140+
# Default HF pipeline path. Models known to lack native timestamps
141+
# default to return_timestamps=False so the pipeline does not raise;
142+
# callers can override by passing return_timestamps explicitly.
143+
if "return_timestamps" not in kwargs and str(model.path_or_uri).startswith(_TIMESTAMP_LESS_HF_MODELS):
144+
kwargs["return_timestamps"] = False
102145
return HuggingFaceASR.transcribe_audios_with_transformers(
103146
audios=audios, model=model, language=language, device=device, **kwargs
104147
)

0 commit comments

Comments
 (0)