-
Notifications
You must be signed in to change notification settings - Fork 10
Expand file tree
/
Copy pathcanary_qwen.py
More file actions
198 lines (172 loc) · 8.24 KB
/
Copy pathcanary_qwen.py
File metadata and controls
198 lines (172 loc) · 8.24 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
"""NVIDIA Canary-Qwen 2.5B ASR via isolated subprocess venv.
Canary-Qwen is loaded via NeMo's ``SALM`` class (Speech-Augmented Language
Model) from ``nemo.collections.speechlm2.models`` — a different code path
than the existing NeMo ASR flow in ``nemo.py`` (which uses
``nemo.collections.asr.models.ASRModel``). It also requires a wider set
of NeMo extras (``[asr,tts]``) and currently a NeMo trunk pin, so we
isolate it in a SEPARATE venv from ``nemo-diarization`` to avoid
destabilizing the Sortformer / Conformer-CTC paths that already work.
Canary-Qwen is text-only — it has no native timestamp output. The
analyze_audio script's auto-align stage adds per-segment timestamps via
the multilingual MMS forced-aligner (see
``senselab.audio.tasks.forced_alignment``) when this backend is used
through ``transcribe_audios``.
"""
from __future__ import annotations
import json
import subprocess
import tempfile
from pathlib import Path
from typing import List, Optional
from senselab.audio.data_structures import Audio
from senselab.utils.data_structures import DeviceType, HFModel, ScriptLine, _select_device_and_dtype
from senselab.utils.dependencies import hf_subprocess_env
from senselab.utils.subprocess_venv import _clean_subprocess_env, ensure_venv, parse_subprocess_result, venv_python
# Dedicated venv — kept separate from the existing nemo-diarization venv.
# Canary-Qwen needs nemo_toolkit[asr,tts] (the [tts] extra pulls
# speechlm2 dependencies including the Qwen LM components) and a NeMo
# trunk build that publishes SALM. Pinning to the trunk keeps this venv
# updatable without affecting the stable nemo-diarization venv.
_CANARY_VENV = "nemo-canary-qwen"
# NOTE on the torch + torchaudio pins below: the version constraint here is
# necessary but not sufficient on newer-CUDA hosts. PyPI's default resolver
# can pick `torch` and `torchaudio` built for different CUDA toolchains,
# which breaks their ABI contract at import. The shared ``ensure_venv``
# routes the install through the matching PyTorch wheel index
# (``cu128``/``cu126``/``cu124``/``cu121``/``cpu``) — that's what
# guarantees the toolchain match. Do not add a backend-local install path
# that bypasses ``ensure_venv``.
_CANARY_REQUIREMENTS = [
# NeMo trunk publishes SALM via nemo.collections.speechlm2.models.
# When NeMo cuts a stable release that includes SALM, swap this for a
# version pin (e.g., "nemo_toolkit[asr,tts]>=2.5"); for now trunk is
# the only path.
"nemo_toolkit[asr,tts] @ git+https://github.com/NVIDIA/NeMo.git",
"torch>=2.8,<2.9",
"torchaudio>=2.8,<2.9",
"pyarrow<18",
"matplotlib",
"soundfile",
]
_CANARY_PYTHON = "3.12"
# Worker script — runs inside the isolated venv.
# The chat-style prompt format with ``audio_locator_tag`` plus
# ``{"audio": [path]}`` matches the published Canary-Qwen model card
# example. Decoding via ``model.tokenizer.ids_to_text(ids)`` recovers
# the transcribed text from generated token ids.
_CANARY_WORKER_SCRIPT = r"""
import json
import sys
try:
import torch
from nemo.collections.speechlm2.models import SALM
args = json.loads(sys.stdin.read())
audio_paths = args["audio_paths"]
model_name = args["model_name"]
device = args["device"]
model = SALM.from_pretrained(model_name)
if device == "cuda" and torch.cuda.is_available():
model = model.cuda()
all_results = []
with torch.no_grad():
for path in audio_paths:
# SALM.generate expects prompts as list[list[dict]] — a batch of
# conversations, where each conversation is a list of messages.
prompts = [
[
{
"role": "user",
"content": f"Transcribe the following: {model.audio_locator_tag}",
"audio": [path],
}
]
]
output_ids = model.generate(prompts=prompts, max_new_tokens=512)
# output_ids shape: (batch, seq_len). Decode the full output. NeMo
# SALM normally returns only the completion tokens, but if a future
# build echoes the prompt we strip the leading "Transcribe the
# following: ..." preamble so it doesn't leak into the transcript.
row = output_ids[0]
ids = row.tolist() if hasattr(row, "tolist") else list(row)
text = model.tokenizer.ids_to_text(ids)
stripped = text.strip()
prompt_marker = "Transcribe the following:"
if prompt_marker in stripped:
# Take everything after the last occurrence of the marker — covers
# prompt-echo without dropping the marker if it appears in the
# source audio (vanishingly rare).
stripped = stripped.rsplit(prompt_marker, 1)[-1].strip()
all_results.append({"text": stripped})
print(json.dumps({"results": all_results}))
except Exception as exc:
import traceback
err = {
"type": type(exc).__name__,
"message": str(exc),
"traceback": traceback.format_exc(limit=5),
}
print(json.dumps({"error": err}))
sys.exit(1)
"""
class CanaryQwenASR:
"""NVIDIA Canary-Qwen 2.5B transcription via isolated subprocess venv.
Routed automatically by ``senselab.audio.tasks.speech_to_text.api`` when
the model id matches the ``nvidia/canary-`` prefix. Returns text-only
ScriptLines (Canary-Qwen does not emit native timestamps); pair with
the multilingual forced-aligner downstream to add per-segment timing.
"""
@classmethod
def transcribe_with_canary_qwen(
cls,
audios: List[Audio],
model: Optional[HFModel] = None,
device: Optional[DeviceType] = None,
) -> List[ScriptLine]:
"""Transcribe audios with Canary-Qwen via the dedicated subprocess venv.
Args:
audios: Audio clips to transcribe (mono, 16 kHz expected).
model: HF model id (default: ``nvidia/canary-qwen-2.5b``).
device: CPU or CUDA. CUDA strongly recommended; CPU works but
is very slow for a 2.5B-parameter model.
Returns:
One ``ScriptLine`` per input audio with ``text`` populated.
``start``, ``end``, and ``chunks`` are intentionally None /
empty — Canary-Qwen does not produce native timestamps.
Downstream auto-alignment (MMS) adds per-segment timing.
"""
model_name = model.path_or_uri if model is not None else "nvidia/canary-qwen-2.5b"
device_type = device or _select_device_and_dtype(compatible_devices=[DeviceType.CUDA, DeviceType.CPU])[0]
venv_dir = ensure_venv(_CANARY_VENV, _CANARY_REQUIREMENTS, python_version=_CANARY_PYTHON)
python = venv_python(venv_dir)
with tempfile.TemporaryDirectory(prefix="senselab-canary-qwen-") as tmpdir:
tmp = Path(tmpdir)
audio_paths: List[str] = []
for i, audio in enumerate(audios):
path = str(tmp / f"audio_{i}.wav")
audio.save_to_file(path)
audio_paths.append(path)
input_json = json.dumps(
{
"audio_paths": audio_paths,
"model_name": model_name,
"device": device_type.value,
}
)
# Ensure the model is cached once (cross-node locked) and tell the
# child to load from the local cache only, so its SALM.from_pretrained
# skips the HF Hub revision check that 429s under many parallel jobs.
revision = model.revision if model is not None else "main"
env = hf_subprocess_env(model_name, revision or "main", base_env=_clean_subprocess_env())
result = subprocess.run(
[python, "-c", _CANARY_WORKER_SCRIPT],
input=input_json,
capture_output=True,
text=True,
timeout=1200, # 2.5B-param model load + per-audio generate; allow 20 min.
env=env,
)
output = parse_subprocess_result(result, "Canary-Qwen ASR")
results: List[ScriptLine] = []
for entry in output.get("results", []):
results.append(ScriptLine(text=entry.get("text", "")))
return results