kehanlu commited on
Commit
9d45136
·
verified ·
1 Parent(s): 22f0e06

Upload folder using huggingface_hub

Browse files
.gitattributes CHANGED
@@ -33,3 +33,7 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
 
 
 
 
 
33
  *.zip filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
+ samples/1272-128104-0000.flac filter=lfs diff=lfs merge=lfs -text
37
+ samples/1272-128104-0001.flac filter=lfs diff=lfs merge=lfs -text
38
+ samples/1272-128104-0002.flac filter=lfs diff=lfs merge=lfs -text
39
+ samples/1272-128104-0003.flac filter=lfs diff=lfs merge=lfs -text
README.md ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: apache-2.0
3
+ language: en
4
+ tags: [speech-llm, desta, librispeech, tutorial]
5
+ ---
6
+
7
+ # Interspeech tutorial — DeSTA-style SpeechLLM checkpoints
8
+
9
+ Whisper-large-v3 encoder (frozen) → concat+MLP adapter → Qwen3-4B-Instruct-2507 + LoRA r32.
10
+ Only the adapter and the LoRA weights are trained, so each checkpoint is ~147 MB; the base
11
+ models are downloaded from their own repos at load time.
12
+
13
+ | folder | training data | test-clean WER | test-clean gender |
14
+ |---|---|---|---|
15
+ | `selfgen_only` | 281k self-generated conversational responses (no task labels) | 3.93 (best prompt + post-processing) | 98.24 |
16
+ | `selfgen_mix` | the same + 20% ASR + 5% gender SFT | 2.45 | 98.82 |
17
+
18
+ The self-generation targets were written by Qwen3-4B given only the transcript and the
19
+ speaker's gender: `<audio>{transcription} (Gender: {gender})</audio>` + "The audio is a
20
+ passage read aloud from a book. Respond directly as a natural conversation partner. Do not
21
+ mention the audio, the transcription, or the speaker attributes."
22
+
23
+ `selfgen_only` has never seen a transcription instruction: it can transcribe, but needs an
24
+ explicit prompt ("Transcribe the speech word for word. Output only the transcription …") and
25
+ sometimes answers in the training-time metadata format. `selfgen_mix` follows the plain
26
+ prompts directly.
27
+
28
+ ## Usage
29
+
30
+ See the Colab notebook in the tutorial repo, or:
31
+
32
+ ```python
33
+ from huggingface_hub import hf_hub_download, snapshot_download
34
+ import sys, torch
35
+
36
+ code = snapshot_download("kehanlu/interspeech-tutorial", allow_patterns="code/*")
37
+ sys.path.insert(0, f"{code}/code")
38
+ from inference import SpeechLLMForInference
39
+
40
+ ckpt = hf_hub_download("kehanlu/interspeech-tutorial", "selfgen_mix/model.ckpt")
41
+ pipe = SpeechLLMForInference.from_checkpoint(ckpt, dtype=torch.float16) # float16 for a Colab T4
42
+ print(pipe.generate([{"role": "user",
43
+ "content": "<audio><|AUDIO|></audio>\n\nTranscribe the speech into text",
44
+ "audios": [{"audio": "sample.flac"}]}]))
45
+ ```
code/data.py ADDED
@@ -0,0 +1,320 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Data pipeline for the minimal SpeechLLM.
3
+
4
+ The one idea to hold on to while reading this file: **the LLM never sees audio.**
5
+ It sees a plain text token sequence in which every ``<|AUDIO|>`` has been replaced
6
+ by exactly as many placeholder tokens as that audio will occupy after encoding.
7
+ The model later overwrites those placeholders' *embeddings* with real speech
8
+ features (see modeling.py). Nothing else about the LLM changes.
9
+
10
+ Everything that turns "a manifest row" into "model inputs" happens in `Collator`.
11
+ The `Dataset` is a dumb jsonl reader. Keeping it in one place matters here: the
12
+ number of placeholders depends on the audio's true duration, which we only know
13
+ once the waveform is loaded -- so the expansion has to happen next to the audio.
14
+
15
+ Manifest format (one JSON object per line)::
16
+
17
+ {
18
+ "audios": [
19
+ {"audio_filepath": "ESC50/1-103999-A-30.wav"}
20
+ ],
21
+ "messages": [
22
+ {"role": "user", "content": "What do you hear? <|AUDIO|>"}
23
+ ],
24
+ "target": "A wooden door being knocked on."
25
+ }
26
+
27
+ `audio_filepath` is relative to `data_root`. `target` may also be called
28
+ `response` (both appear in the DeSTA3 manifests). Extra keys are ignored.
29
+ """
30
+
31
+ import json
32
+ import logging
33
+ import math
34
+ import os
35
+ import random
36
+ from dataclasses import dataclass, field
37
+ from typing import Any, List, Optional
38
+
39
+ import librosa
40
+ import torch
41
+ from torch.utils.data import Dataset
42
+
43
+ logger = logging.getLogger(__name__)
44
+
45
+ SAMPLE_RATE = 16000
46
+
47
+
48
+ # --------------------------------------------------------------------------
49
+ # How many tokens will one audio occupy?
50
+ #
51
+ # Whisper's feature extractor always pads to 30 s, so we cannot use the padded
52
+ # length -- a 5 s clip would reserve 25 s worth of slots. We ask the feature
53
+ # extractor for an attention mask over the mel frames instead, and push the true
54
+ # length through the same conv arithmetic the model uses.
55
+ #
56
+ # These formulas mirror Qwen2AudioEncoder._get_feat_extract_output_lengths.
57
+ # --------------------------------------------------------------------------
58
+
59
+ def whisper_frames(mel_len: int) -> int:
60
+ """Whisper's encoder conv2 has stride 2. 3000 mel frames -> 1500 positions."""
61
+ return (mel_len - 1) // 2 + 1
62
+
63
+
64
+ def conv1d_out(length: int, kernel: int = 3, stride: int = 2, padding: int = 1) -> int:
65
+ return (length + 2 * padding - kernel) // stride + 1
66
+
67
+
68
+ def audio_token_count(mel_len: int, n_downsample_layers: int) -> int:
69
+ """mel frames -> number of LLM token slots this audio needs."""
70
+ length = whisper_frames(mel_len)
71
+ for _ in range(n_downsample_layers):
72
+ length = conv1d_out(length)
73
+ return length
74
+
75
+
76
+ def expand_audio_locator(tokens: List[str], audio_locator: str,
77
+ placeholder_token: str, slot_sizes: List[int]):
78
+ """Replace each `audio_locator` with `slot_sizes[i]` placeholder tokens.
79
+
80
+ ``["a", "<|AUDIO|>", "b"]`` with ``slot_sizes=[3]`` becomes
81
+ ``["a", "<|pad|>", "<|pad|>", "<|pad|>", "b"]`` and ``start_positions=[1]``.
82
+ """
83
+ out: List[str] = []
84
+ starts: List[int] = []
85
+ sizes = iter(slot_sizes)
86
+ for token in tokens:
87
+ if token == audio_locator:
88
+ starts.append(len(out))
89
+ out.extend([placeholder_token] * next(sizes))
90
+ else:
91
+ out.append(token)
92
+ return out, starts
93
+
94
+
95
+ def resolve_manifest(path: str) -> str:
96
+ """Accept a local path or a huggingface.co/datasets/... resolve URL."""
97
+ prefix = "https://huggingface.co/datasets/"
98
+ if not path.startswith(prefix):
99
+ return path
100
+ from huggingface_hub import hf_hub_download
101
+
102
+ parts = path[len(prefix):].split("/")
103
+ repo_id, marker, revision = "/".join(parts[:2]), parts[2], parts[3]
104
+ assert marker == "resolve", f"unsupported HF url: {path}"
105
+ return hf_hub_download(repo_id=repo_id, filename="/".join(parts[4:]),
106
+ revision=revision, repo_type="dataset")
107
+
108
+
109
+ def resolve_audio_filepath(path: str) -> str:
110
+ if os.path.exists(path):
111
+ return path
112
+ wav = os.path.splitext(path)[0] + ".wav"
113
+ if os.path.exists(wav):
114
+ return wav
115
+ raise FileNotFoundError(path)
116
+
117
+
118
+ def split_manifest_entries(entries):
119
+ """Manifest entries are a path (ratio 1.0) or {"path": ..., "ratio": ...}.
120
+
121
+ Returns (paths, ratios).
122
+ """
123
+ paths, ratios = [], []
124
+ for entry in entries:
125
+ if isinstance(entry, str):
126
+ path, ratio = entry, 1.0
127
+ else:
128
+ unknown = set(entry) - {"path", "ratio"}
129
+ assert "path" in entry and not unknown, f"bad manifest entry {entry!r}"
130
+ path, ratio = entry["path"], float(entry.get("ratio", 1.0))
131
+ assert ratio >= 0, f"{path}: ratio must be >= 0, got {ratio}"
132
+ paths.append(path)
133
+ ratios.append(ratio)
134
+ return paths, ratios
135
+
136
+
137
+ def manifest_name(path: str) -> str:
138
+ return os.path.basename(path).removesuffix(".jsonl")
139
+
140
+
141
+ class AudioTextDataset(Dataset):
142
+ """Lazy jsonl reader with per-manifest mixing ratios.
143
+
144
+ The DeSTA3 manifests run to hundreds of MB each, so we build a byte-offset
145
+ index up front and read one line at a time instead of holding the parsed
146
+ rows in memory.
147
+
148
+ `ratios[i]` scales manifest i: 0.3 keeps a random 30% of its rows, 2.5 keeps two
149
+ full copies plus a random 50%. The random part is drawn from (seed, epoch,
150
+ manifest) only -- never the rank -- so every DDP rank builds the same index
151
+ and DistributedSampler shards it consistently. Call `resample(epoch)` to draw a
152
+ fresh subset for a new epoch; the length does not change.
153
+ """
154
+
155
+ def __init__(self, manifest_filepaths, data_root: str, ratios=None, seed: int = 0):
156
+ self.data_root = data_root
157
+ self.paths = [resolve_manifest(p) for p in manifest_filepaths]
158
+ self.ratios = list(ratios) if ratios is not None else [1.0] * len(self.paths)
159
+ assert len(self.ratios) == len(self.paths)
160
+ self.seed = seed
161
+ self.rows = [] # per manifest: [(file_idx, byte_offset), ...]
162
+ for file_idx, path in enumerate(self.paths):
163
+ rows = []
164
+ with open(path, "rb") as f:
165
+ offset = 0
166
+ for line in f:
167
+ if line.strip():
168
+ rows.append((file_idx, offset))
169
+ offset += len(line)
170
+ self.rows.append(rows)
171
+ self._handles = {} # opened lazily, per dataloader worker
172
+ self.resample(epoch=0, log=True)
173
+
174
+ @property
175
+ def needs_resampling(self) -> bool:
176
+ """True when some ratio has a fractional part, i.e. a random subset is drawn."""
177
+ return any(r != math.floor(r) for r in self.ratios)
178
+
179
+ def resample(self, epoch: int, log: bool = False):
180
+ self.index = [] # (file_idx, byte_offset)
181
+ for file_idx, (rows, ratio) in enumerate(zip(self.rows, self.ratios)):
182
+ whole = math.floor(ratio)
183
+ n_extra = round((ratio - whole) * len(rows))
184
+ # str seed: hashed with sha512, identical across processes and runs
185
+ rng = random.Random(f"{self.seed}-{epoch}-{file_idx}")
186
+ self.index += rows * whole + rng.sample(rows, n_extra)
187
+ if log:
188
+ logger.info(" %-32s ratio %.2f: %d of %d rows", manifest_name(self.paths[file_idx]),
189
+ ratio, whole * len(rows) + n_extra, len(rows))
190
+ if log:
191
+ logger.info("loaded %d rows from %d manifest(s)", len(self.index), len(self.paths))
192
+
193
+ def __len__(self):
194
+ return len(self.index)
195
+
196
+ def __getitem__(self, i):
197
+ file_idx, offset = self.index[i]
198
+ handle = self._handles.get(file_idx)
199
+ if handle is None:
200
+ handle = self._handles[file_idx] = open(self.paths[file_idx], "rb")
201
+ handle.seek(offset)
202
+ row = json.loads(handle.readline())
203
+
204
+ # DeSTA3 manifests use either key; `response` wins when both are present.
205
+ target = row.get("response") or row.get("target")
206
+ assert target, f"row {i} has neither `response` nor `target`"
207
+
208
+ audios = [{"audio_filepath": resolve_audio_filepath(
209
+ os.path.join(self.data_root, audio["audio_filepath"]))}
210
+ for audio in row["audios"]]
211
+ return {"messages": row["messages"], "target": target, "audios": audios}
212
+
213
+
214
+ @dataclass
215
+ class Collator:
216
+ """Turns a list of rows into the tensors `SpeechLLM.forward` expects.
217
+
218
+ Padding is on the **left** so that every sequence's answer ends at the same
219
+ index -- that keeps `generate()` simple. It also means every position we
220
+ record has to be shifted by that sequence's pad length.
221
+ """
222
+
223
+ tokenizer: Any
224
+ feature_extractor: Any
225
+ audio_locator: str = "<|AUDIO|>"
226
+ placeholder_token: str = "<|vision_pad|>"
227
+ max_seq_length: int = 1024
228
+ max_audio_seconds: float = 30.0
229
+ n_downsample_layers: int = 1
230
+ # inference: build the prompt only, with no answer appended and no labels.
231
+ # Same slot arithmetic as training -- that is the whole point of reusing this
232
+ # class rather than rebuilding the expansion in the demo script.
233
+ for_generation: bool = False
234
+
235
+ def __post_init__(self):
236
+ assert self.tokenizer.padding_side == "left"
237
+ assert self.placeholder_token in self.tokenizer.get_vocab(), (
238
+ f"placeholder_token {self.placeholder_token!r} is not in the tokenizer "
239
+ "vocabulary; pick a reserved/unused token of your LLM")
240
+ self.placeholder_id = self.tokenizer.convert_tokens_to_ids(self.placeholder_token)
241
+ self.pad_id = self.tokenizer.pad_token_id
242
+
243
+ def __call__(self, batch):
244
+ # 1. audio -> mel features, and the true (unpadded) length of each
245
+ waveforms = []
246
+ for row in batch:
247
+ for audio in row["audios"]:
248
+ wav, _ = librosa.load(audio["audio_filepath"], sr=SAMPLE_RATE, mono=True)
249
+ waveforms.append(wav[:int(self.max_audio_seconds * SAMPLE_RATE)])
250
+
251
+ features = self.feature_extractor(
252
+ waveforms, sampling_rate=SAMPLE_RATE,
253
+ return_tensors="pt", return_attention_mask=True,
254
+ )
255
+ mel_lengths = features["attention_mask"].sum(-1).tolist()
256
+ audio_lengths = [audio_token_count(n, self.n_downsample_layers) for n in mel_lengths]
257
+
258
+ # 2. text -> token ids, with <|AUDIO|> expanded to placeholders
259
+ context_ids, target_ids = [], []
260
+ start_positions, audio_index = [], 0
261
+
262
+ for row in batch:
263
+ n_audios = len(row["audios"])
264
+ # each audio reserves one slot per speech feature frame
265
+ slot_sizes = audio_lengths[audio_index:audio_index + n_audios]
266
+
267
+ prompt = self.tokenizer.apply_chat_template(
268
+ row["messages"], tokenize=False, add_generation_prompt=True,
269
+ enable_thinking=False,
270
+ )
271
+ tokens = self.tokenizer.tokenize(prompt)
272
+ assert tokens.count(self.audio_locator) == n_audios, (
273
+ f"{n_audios} audios but {tokens.count(self.audio_locator)} "
274
+ f"{self.audio_locator} in the prompt")
275
+
276
+ tokens, starts = expand_audio_locator(
277
+ tokens, self.audio_locator, self.placeholder_token, slot_sizes)
278
+
279
+ # straight to ids -- no convert_tokens_to_string round-trip, so the
280
+ # placeholder can never be re-tokenized into something else
281
+ ctx = self.tokenizer.convert_tokens_to_ids(tokens)
282
+ if self.for_generation:
283
+ tgt = [] # nothing to condition on; the model writes it
284
+ else:
285
+ tgt = self.tokenizer.encode(row["target"], add_special_tokens=False)
286
+ tgt = tgt + [self.tokenizer.eos_token_id]
287
+
288
+ assert len(ctx) < self.max_seq_length, (
289
+ f"prompt alone is {len(ctx)} tokens (max_seq_length="
290
+ f"{self.max_seq_length}); shorten the audio or raise the limit")
291
+ tgt = tgt[:self.max_seq_length - len(ctx)] # only ever truncate the answer
292
+
293
+ context_ids.append(ctx)
294
+ target_ids.append(tgt)
295
+ start_positions.append(starts)
296
+ audio_index += n_audios
297
+
298
+ # 3. left-pad, build labels, shift the recorded positions
299
+ width = max(len(c) + len(t) for c, t in zip(context_ids, target_ids))
300
+ input_ids = torch.full((len(batch), width), self.pad_id, dtype=torch.long)
301
+ attention_mask = torch.zeros((len(batch), width), dtype=torch.long)
302
+ labels = torch.full((len(batch), width), -100, dtype=torch.long)
303
+ shifted_starts = []
304
+
305
+ for i, (ctx, tgt) in enumerate(zip(context_ids, target_ids)):
306
+ pad = width - len(ctx) - len(tgt)
307
+ input_ids[i, pad:] = torch.tensor(ctx + tgt, dtype=torch.long)
308
+ attention_mask[i, pad:] = 1
309
+ labels[i, pad + len(ctx):] = torch.tensor(tgt, dtype=torch.long) # answer only
310
+ for start in start_positions[i]:
311
+ shifted_starts.append((i, start + pad))
312
+
313
+ return {
314
+ "input_ids": input_ids,
315
+ "attention_mask": attention_mask,
316
+ "labels": labels,
317
+ "input_features": features["input_features"],
318
+ "audio_lengths": audio_lengths, # per audio, after downsampling
319
+ "start_positions": shifted_starts, # per audio, (row, first placeholder)
320
+ }
code/inference.py ADDED
@@ -0,0 +1,205 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python
2
+ """
3
+ Inference for the minimal SpeechLLM -- the tutorial's demo entry point.
4
+
5
+ # one file
6
+ python inference.py --ckpt exp/run/checkpoints/last.ckpt --audio sample.flac
7
+
8
+ # score a manifest: prints reference vs hypothesis side by side
9
+ python inference.py --ckpt exp/run/checkpoints/last.ckpt \
10
+ --manifest data/Librispeech-dev-test/test-clean_asr.jsonl \
11
+ --data-root /home/u8915687/work/data/audios2 --limit 20
12
+
13
+ The architecture is read back out of the checkpoint's `hyper_parameters`, never
14
+ hand-written here. That matters more than it looks: `SpeechLLMModule` loads with
15
+ `strict=False`, and the connector's downsampling stack is an `nn.Sequential`
16
+ whose keys are positional (`downsample.0.*`, `downsample.2.*`). Build the model
17
+ with the wrong `n_downsample_layers` and those tensors become "missing keys",
18
+ which `strict=False` silently drops -- you get a randomly-initialised connector,
19
+ no error, and fluent nonsense out. `_load_trainable` below turns that into a
20
+ hard failure instead.
21
+
22
+ Unlike DeSTA3 there is no VAD and no ASR decoder in the loop: DeSTA3 runs Whisper's
23
+ decoder to produce a transcript, and needs VAD to decide which clips are speech
24
+ at all. Here the LLM only sees the speech features, exactly as in training.
25
+ """
26
+
27
+ import argparse
28
+ import json
29
+ import logging
30
+ import os
31
+
32
+ import torch
33
+ from transformers import AutoFeatureExtractor, AutoTokenizer
34
+
35
+ from data import Collator
36
+ from modeling import SpeechLLM
37
+
38
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
39
+ logger = logging.getLogger(__name__)
40
+
41
+ DEFAULT_PROMPT = "<audio><|AUDIO|></audio>\n\nTranscribe the speech into text"
42
+
43
+
44
+ class SpeechLLMForInference:
45
+ """Checkpoint in, text out.
46
+
47
+ `generate()` takes DeSTA3-style messages so the demo reads like a chat call:
48
+
49
+ [{"role": "user",
50
+ "content": "<audio><|AUDIO|></audio>\\n\\nTranscribe the speech into text",
51
+ "audios": [{"audio": "sample.flac"}]}]
52
+
53
+ Pass a list of those to batch several utterances in one forward pass.
54
+ """
55
+
56
+ def __init__(self, model, tokenizer, collator, device, dtype):
57
+ self.model, self.tokenizer = model, tokenizer
58
+ self.collator, self.device, self.dtype = collator, device, dtype
59
+
60
+ # -- loading ----------------------------------------------------------
61
+ @classmethod
62
+ def from_checkpoint(cls, ckpt_path, device=None, dtype=None):
63
+ device = device or ("cuda" if torch.cuda.is_available() else "cpu")
64
+ ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
65
+ hp = ckpt["hyper_parameters"]
66
+ dtype = dtype or getattr(torch, hp["dtype"])
67
+ logger.info("checkpoint %s (epoch %s, step %s)",
68
+ os.path.basename(ckpt_path), ckpt.get("epoch"), ckpt.get("global_step"))
69
+ logger.info(" llm=%s encoder=%s adapter=%s n_downsample=%d frozen_encoder=%s",
70
+ hp["llm_id"], hp["encoder_id"], hp.get("adapter_type", "cnn"),
71
+ hp["n_downsample_layers"], hp.get("freeze_encoder", False))
72
+
73
+ # rebuilt exactly as SpeechLLMModule.__init__ does, or the ids shift
74
+ tokenizer = AutoTokenizer.from_pretrained(hp["llm_id"])
75
+ tokenizer.pad_token = tokenizer.eos_token
76
+ tokenizer.padding_side = "left"
77
+ tokenizer.add_tokens([hp["audio_locator"]])
78
+ feature_extractor = AutoFeatureExtractor.from_pretrained(hp["encoder_id"])
79
+
80
+ model = SpeechLLM(
81
+ llm_id=hp["llm_id"], encoder_id=hp["encoder_id"],
82
+ n_downsample_layers=hp["n_downsample_layers"], dtype=dtype,
83
+ # .get(): checkpoints from before these options were cnn + trained encoder
84
+ adapter_type=hp.get("adapter_type", "cnn"),
85
+ adapter_hidden_dim=hp.get("adapter_hidden_dim", 0),
86
+ freeze_encoder=hp.get("freeze_encoder", False), use_lora=True,
87
+ lora_rank=hp["lora_rank"], lora_alpha=hp["lora_alpha"],
88
+ lora_dropout=hp["lora_dropout"],
89
+ lora_target_modules=hp["lora_target_modules"],
90
+ )
91
+ cls._load_trainable(model, ckpt["state_dict"])
92
+ model.to(device).eval()
93
+
94
+ collator = Collator(
95
+ tokenizer=tokenizer, feature_extractor=feature_extractor,
96
+ audio_locator=hp["audio_locator"], placeholder_token=hp["placeholder_token"],
97
+ max_seq_length=hp["max_seq_length"], max_audio_seconds=hp["max_audio_seconds"],
98
+ n_downsample_layers=hp["n_downsample_layers"],
99
+ for_generation=True,
100
+ )
101
+ return cls(model, tokenizer, collator, device, dtype)
102
+
103
+ @staticmethod
104
+ def _load_trainable(model, state_dict):
105
+ """Load the trainable-only checkpoint, and prove every tensor landed."""
106
+ state = {k[len("model."):]: v for k, v in state_dict.items() if k.startswith("model.")}
107
+ expected = {n for n, p in model.named_parameters() if p.requires_grad}
108
+ missing, unexpected = expected - set(state), set(state) - expected
109
+ assert not unexpected, (
110
+ f"{len(unexpected)} tensors in the checkpoint match nothing in the model, "
111
+ f"e.g. {sorted(unexpected)[:3]} -- the architecture does not match")
112
+ assert not missing, (
113
+ f"{len(missing)} trainable tensors were not in the checkpoint, "
114
+ f"e.g. {sorted(missing)[:3]} -- they would stay randomly initialised")
115
+ result = model.load_state_dict(state, strict=False)
116
+ assert not result.unexpected_keys, result.unexpected_keys
117
+ logger.info(" restored %d trainable tensors (%.1fM params)", len(state),
118
+ sum(v.numel() for v in state.values()) / 1e6)
119
+
120
+ # -- generation -------------------------------------------------------
121
+ def _to_rows(self, conversations):
122
+ """DeSTA3-style messages -> the row dicts `Collator` consumes."""
123
+ rows = []
124
+ for conv in conversations:
125
+ audios = []
126
+ for message in conv:
127
+ for audio in message.get("audios", []):
128
+ path = audio["audio"] if isinstance(audio, dict) else audio
129
+ assert os.path.exists(path), f"no such audio: {path}"
130
+ audios.append({"audio_filepath": path})
131
+ n_locators = sum(m["content"].count(self.collator.audio_locator) for m in conv)
132
+ assert n_locators == len(audios), (
133
+ f"{len(audios)} audios but {n_locators} {self.collator.audio_locator} "
134
+ "in the conversation")
135
+ # strip `audios` before the chat template ever sees it
136
+ rows.append({"messages": [{"role": m["role"], "content": m["content"]} for m in conv],
137
+ "audios": audios, "target": ""})
138
+ return rows
139
+
140
+ @torch.no_grad()
141
+ def generate(self, conversations, max_new_tokens=200, do_sample=False, **generation_kwargs):
142
+ if conversations and isinstance(conversations[0], dict):
143
+ conversations = [conversations] # a single conversation
144
+ batch = self.collator(self._to_rows(conversations))
145
+
146
+ batch["input_ids"] = batch["input_ids"].to(self.device)
147
+ batch["attention_mask"] = batch["attention_mask"].to(self.device)
148
+ # SpeechLLM.encode_audio casts features to the encoder's own dtype
149
+ batch["input_features"] = batch["input_features"].to(self.device)
150
+
151
+ # passing inputs_embeds means `generate` returns only the new tokens --
152
+ # there is no prompt prefix to slice off
153
+ generated = self.model.generate(
154
+ batch, self.tokenizer, max_new_tokens=max_new_tokens,
155
+ do_sample=do_sample, **generation_kwargs)
156
+ return [t.strip() for t in
157
+ self.tokenizer.batch_decode(generated, skip_special_tokens=True)]
158
+
159
+
160
+ # ------------------------------------------------------------------ main ---
161
+
162
+ def main():
163
+ ap = argparse.ArgumentParser(description=__doc__,
164
+ formatter_class=argparse.RawDescriptionHelpFormatter)
165
+ ap.add_argument("--ckpt", required=True)
166
+ ap.add_argument("--audio", nargs="+", help="one or more audio files")
167
+ ap.add_argument("--manifest", help="jsonl to run over instead of --audio")
168
+ ap.add_argument("--data-root", default="")
169
+ ap.add_argument("--limit", type=int, default=10, help="rows to take from --manifest")
170
+ ap.add_argument("--prompt", default=DEFAULT_PROMPT)
171
+ ap.add_argument("--batch-size", type=int, default=4)
172
+ ap.add_argument("--max-new-tokens", type=int, default=200)
173
+ ap.add_argument("--device", default=None)
174
+ args = ap.parse_args()
175
+ assert args.audio or args.manifest, "give --audio or --manifest"
176
+
177
+ pipe = SpeechLLMForInference.from_checkpoint(args.ckpt, device=args.device)
178
+
179
+ if args.audio:
180
+ items = [(os.path.join(args.data_root, p), None) for p in args.audio]
181
+ else:
182
+ items = []
183
+ with open(args.manifest) as f:
184
+ for line in f:
185
+ if len(items) >= args.limit:
186
+ break
187
+ row = json.loads(line)
188
+ items.append((os.path.join(args.data_root,
189
+ row["audios"][0]["audio_filepath"]),
190
+ row.get("response") or row.get("target")))
191
+
192
+ for start in range(0, len(items), args.batch_size):
193
+ chunk = items[start:start + args.batch_size]
194
+ convs = [[{"role": "user", "content": args.prompt,
195
+ "audios": [{"audio": path}]}] for path, _ in chunk]
196
+ for (path, reference), hypothesis in zip(chunk, pipe.generate(
197
+ convs, max_new_tokens=args.max_new_tokens)):
198
+ print(f"\n--- {os.path.basename(path)}")
199
+ if reference is not None:
200
+ print(f" ref: {reference}")
201
+ print(f" hyp: {hypothesis}")
202
+
203
+
204
+ if __name__ == "__main__":
205
+ main()
code/modeling.py ADDED
@@ -0,0 +1,252 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ A minimal SpeechLLM: Whisper encoder + connector + LLM with LoRA.
3
+
4
+ audio ──► Whisper encoder (trained or frozen) ──► connector (trained) ──┐
5
+ ├──► LLM + LoRA ──► text
6
+ text ────────────────────────────────────────► embedding table ────────┘
7
+
8
+ Two connectors (`adapter_type`), both 50 fps -> 25 fps per downsampling step:
9
+ cnn strided Conv1d + GELU, then LayerNorm + Linear
10
+ concat_mlp concatenate adjacent frames, then LayerNorm + Linear + GELU + Linear
11
+
12
+ A frozen LLM does *not* make training cheap: the speech features enter at layer 0,
13
+ so gradients still flow back through every LLM layer to reach the connector, and
14
+ every LLM activation has to be kept for the backward pass. Turn on
15
+ `gradient_checkpointing` if that hurts.
16
+ """
17
+
18
+ import logging
19
+
20
+ import torch
21
+ import torch.nn as nn
22
+ from transformers import AutoModelForCausalLM, WhisperModel
23
+
24
+ logger = logging.getLogger(__name__)
25
+
26
+
27
+ class SpeechConnector(nn.Module):
28
+ """Whisper hidden states → LLM-width embeddings.
29
+
30
+ Takes the encoder's last hidden state and applies two steps:
31
+
32
+ 1. **Strided Conv1d downsampling.** Whisper emits 50 frames/s, which is far
33
+ too many tokens to hand an LLM. One stride-2 convolution brings that to
34
+ 25 frames/s -- 40 ms per token, the same rate as Qwen2-Audio. Each extra
35
+ layer halves the rate again.
36
+
37
+ 2. **Linear projection to the LLM's width** (from Qwen2-Audio, whose entire
38
+ connector is ``AvgPool1d(2)`` + ``Linear``).
39
+ """
40
+
41
+ def __init__(self, d_encoder: int, d_llm: int, n_downsample_layers: int = 1):
42
+ super().__init__()
43
+ layers = []
44
+ for _ in range(n_downsample_layers):
45
+ layers += [nn.Conv1d(d_encoder, d_encoder, kernel_size=3, stride=2, padding=1),
46
+ nn.GELU()]
47
+ self.downsample = nn.Sequential(*layers)
48
+
49
+ self.proj = nn.Sequential(nn.LayerNorm(d_encoder), nn.Linear(d_encoder, d_llm))
50
+
51
+ def forward(self, hidden_states): # (B, T, d_encoder)
52
+ hidden_states = self.downsample(hidden_states.transpose(1, 2)).transpose(1, 2)
53
+ return self.proj(hidden_states) # (B, T', d_llm)
54
+
55
+
56
+ class ConcatMLPConnector(nn.Module):
57
+ """Whisper hidden states → LLM-width embeddings by frame concatenation.
58
+
59
+ Every `2 ** n_downsample_layers` adjacent frames are concatenated into one
60
+ feature (d_encoder * k), which a two-layer MLP maps to the LLM's width. This is
61
+ the SLAM-ASR / LLaVA-style projector: no convolution, all mixing happens in the
62
+ MLP. The sequence is right-padded to a multiple of k, so a length-L input gives
63
+ ceil(L / k) tokens -- the same count as the CNN, so `data.audio_token_count`
64
+ serves both.
65
+ """
66
+
67
+ def __init__(self, d_encoder: int, d_llm: int, n_downsample_layers: int = 1,
68
+ hidden_dim: int = 0):
69
+ super().__init__()
70
+ self.k = 2 ** n_downsample_layers
71
+ hidden_dim = hidden_dim or d_llm
72
+ self.proj = nn.Sequential(
73
+ nn.LayerNorm(d_encoder * self.k),
74
+ nn.Linear(d_encoder * self.k, hidden_dim),
75
+ nn.GELU(),
76
+ nn.Linear(hidden_dim, d_llm),
77
+ )
78
+
79
+ def forward(self, hidden_states): # (B, T, d_encoder)
80
+ B, T, D = hidden_states.shape
81
+ pad = (-T) % self.k
82
+ if pad:
83
+ hidden_states = nn.functional.pad(hidden_states, (0, 0, 0, pad))
84
+ stacked = hidden_states.reshape(B, (T + pad) // self.k, D * self.k)
85
+ return self.proj(stacked) # (B, ceil(T/k), d_llm)
86
+
87
+
88
+ ADAPTERS = {"cnn": SpeechConnector, "concat_mlp": ConcatMLPConnector}
89
+
90
+
91
+ class SpeechLLM(nn.Module):
92
+ def __init__(self, llm_id: str, encoder_id: str,
93
+ n_downsample_layers: int = 1, dtype=torch.bfloat16,
94
+ adapter_type: str = "cnn", adapter_hidden_dim: int = 0,
95
+ gradient_checkpointing: bool = False, cache_dir=None,
96
+ freeze_encoder: bool = True, use_lora: bool = False,
97
+ lora_rank: int = 32, lora_alpha: int = 64, lora_dropout: float = 0.05,
98
+ lora_target_modules=("q_proj", "k_proj", "v_proj", "o_proj")):
99
+ super().__init__()
100
+ self.llm = AutoModelForCausalLM.from_pretrained(
101
+ llm_id, torch_dtype=dtype, cache_dir=cache_dir)
102
+ # We only ever need the encoder; loading WhisperModel and keeping .encoder
103
+ # lets the decoder weights fall out of scope immediately.
104
+ self.encoder = WhisperModel.from_pretrained(
105
+ encoder_id, torch_dtype=dtype, cache_dir=cache_dir).encoder
106
+
107
+ assert adapter_type in ADAPTERS, f"adapter_type must be one of {list(ADAPTERS)}"
108
+ extra = {"hidden_dim": adapter_hidden_dim} if adapter_type == "concat_mlp" else {}
109
+ self.connector = ADAPTERS[adapter_type](
110
+ d_encoder=self.encoder.config.d_model,
111
+ d_llm=self.llm.config.hidden_size,
112
+ n_downsample_layers=n_downsample_layers,
113
+ **extra,
114
+ )
115
+ logger.info("adapter %s: %.1fM params", adapter_type,
116
+ sum(p.numel() for p in self.connector.parameters()) / 1e6)
117
+
118
+ # --- what trains ---------------------------------------------------
119
+ # The connector always does. The LLM trains only through LoRA adapters,
120
+ # if at all. The encoder is a switch: leaving it frozen is much cheaper
121
+ # than it looks, because with every encoder parameter frozen and
122
+ # `input_features` not requiring grad, PyTorch builds no graph for the
123
+ # encoder at all -- its activations are never stored. Unfreezing it
124
+ # turns 32 Whisper layers back into stored activations, which costs far
125
+ # more memory than the 635M parameters themselves.
126
+ for param in self.llm.parameters():
127
+ param.requires_grad = False
128
+
129
+ self.use_lora = use_lora
130
+ if use_lora:
131
+ from peft import LoraConfig ,get_peft_model
132
+ lora_config = LoraConfig(
133
+ r=lora_rank, lora_alpha=lora_alpha, lora_dropout=lora_dropout,
134
+ target_modules=list(lora_target_modules), bias="none",
135
+ task_type="CAUSAL_LM",
136
+ )
137
+ # unwrap back to a plain module: we call the LLM directly (with
138
+ # inputs_embeds) rather than through the PeftModel wrapper
139
+ self.llm = get_peft_model(self.llm, lora_config).base_model.model
140
+ n_lora = sum(p.numel() for n, p in self.llm.named_parameters() if "lora_" in n)
141
+ assert n_lora > 0, f"LoRA matched no modules in {lora_target_modules}"
142
+ logger.info("LoRA r=%d on %s: %.1fM adapter params",
143
+ lora_rank, list(lora_target_modules), n_lora / 1e6)
144
+
145
+ self.freeze_encoder = freeze_encoder
146
+ for param in self.encoder.parameters():
147
+ param.requires_grad = not freeze_encoder
148
+
149
+ # Standard mixed precision: frozen weights stay in `dtype`, everything that
150
+ # trains is stored in float32 and autocast does the bf16 compute. bf16 has
151
+ # only 7 fraction bits (~0.8% between neighbouring values), so AdamW's
152
+ # small per-step updates would round to zero on bf16 weights.
153
+ # The connector and LoRA are created in float32 already; the encoder is
154
+ # loaded in `dtype`, so cast it when it trains.
155
+ if not freeze_encoder:
156
+ self.encoder.float()
157
+
158
+ if gradient_checkpointing:
159
+ # use_reentrant=False matters under DDP: the reentrant implementation
160
+ # runs the recomputation outside autograd's view, so DDP can mark a
161
+ # gradient ready twice and abort with "Expected to mark a variable
162
+ # ready only once". The non-reentrant path composes with DDP.
163
+ ckpt_kwargs = {"use_reentrant": False}
164
+ self.llm.gradient_checkpointing_enable(gradient_checkpointing_kwargs=ckpt_kwargs)
165
+ # deliberately no enable_input_require_grads(): the spliced speech
166
+ # features already make inputs_embeds require grad, and that hook
167
+ # would make it a leaf, breaking the splice (see build_inputs_embeds)
168
+ if not freeze_encoder:
169
+ self.encoder.gradient_checkpointing_enable(
170
+ gradient_checkpointing_kwargs=ckpt_kwargs)
171
+
172
+ dtypes = {}
173
+ for name, param in self.named_parameters():
174
+ if param.requires_grad:
175
+ part = name.split(".")[0]
176
+ dtypes.setdefault(part, set()).add(str(param.dtype).replace("torch.", ""))
177
+ logger.info("trainable dtypes: %s", {k: sorted(v) for k, v in sorted(dtypes.items())})
178
+
179
+ by_part = {}
180
+ for name, param in self.named_parameters():
181
+ if param.requires_grad:
182
+ by_part[name.split(".")[0]] = by_part.get(name.split(".")[0], 0) + param.numel()
183
+ trainable = sum(by_part.values())
184
+ total = sum(p.numel() for p in self.parameters())
185
+ logger.info("trainable %.1fM / %.1fM total (%.2f%%) -- %s",
186
+ trainable / 1e6, total / 1e6, 100 * trainable / total,
187
+ {k: f"{v/1e6:.1f}M" for k, v in sorted(by_part.items())})
188
+
189
+ def encode_audio(self, input_features):
190
+ """mel features → speech embeddings in the LLM's width.
191
+
192
+ We run the encoder over the full 30 s window including padding, and crop
193
+ afterwards. Whisper is *trained* that way, so this is its native regime;
194
+ masking the padding would be off-distribution for a frozen encoder.
195
+ """
196
+ input_features = input_features.to(self.encoder.conv1.weight.dtype)
197
+ return self.connector(self.encoder(input_features).last_hidden_state)
198
+
199
+ def build_inputs_embeds(self, input_ids, speech_features, audio_lengths, start_positions):
200
+ """Overwrite each audio's placeholder embeddings with its speech features."""
201
+ embed = self.llm.get_input_embeddings()
202
+ # The embedding table is frozen, so `embed(input_ids)` comes back as a
203
+ # leaf with requires_grad=False -- and writing in-place into a leaf that
204
+ # requires grad is illegal, which is exactly what happens once gradient
205
+ # checkpointing turns that flag on. Clone to get a non-leaf we may write
206
+ # into. Splicing the speech features in is what makes the result require
207
+ # grad, which is also what checkpointing needs from the LLM's input.
208
+ inputs_embeds = embed(input_ids).clone()
209
+
210
+ for k, (row, start) in enumerate(start_positions):
211
+ segment = speech_features[k, :audio_lengths[k]]
212
+ end = start + segment.size(0)
213
+ assert end <= inputs_embeds.size(1), (
214
+ f"audio {k} needs slots [{start}:{end}] but the sequence is "
215
+ f"{inputs_embeds.size(1)} long")
216
+ # outside autocast (inference) the float32 connector output must be
217
+ # cast to the LLM's embedding dtype before the in-place write
218
+ inputs_embeds[row, start:end] = segment.to(inputs_embeds.dtype)
219
+
220
+ return inputs_embeds
221
+
222
+ def forward(self, input_ids, attention_mask, input_features, audio_lengths,
223
+ start_positions, labels=None):
224
+ speech_features = self.encode_audio(input_features)
225
+ inputs_embeds = self.build_inputs_embeds(
226
+ input_ids, speech_features, audio_lengths, start_positions)
227
+ return self.llm(inputs_embeds=inputs_embeds,
228
+ attention_mask=attention_mask,
229
+ labels=labels)
230
+
231
+ @torch.no_grad()
232
+ def generate(self, batch, tokenizer, **generation_kwargs):
233
+ """Greedy/sampled decoding from the same batch dict used for training.
234
+
235
+ Note we pass `inputs_embeds`, so `generate` returns only the newly
236
+ generated ids -- there is no prompt prefix to strip.
237
+ """
238
+ speech_features = self.encode_audio(batch["input_features"])
239
+ inputs_embeds = self.build_inputs_embeds(
240
+ batch["input_ids"], speech_features, batch["audio_lengths"],
241
+ batch["start_positions"])
242
+ return self.llm.generate(
243
+ inputs_embeds=inputs_embeds,
244
+ attention_mask=batch["attention_mask"],
245
+ pad_token_id=tokenizer.pad_token_id,
246
+ **generation_kwargs,
247
+ )
248
+
249
+ def trainable_state_dict(self):
250
+ """Only the connector -- a few tens of MB instead of the full ~11 GB."""
251
+ return {name: param.detach().clone()
252
+ for name, param in self.named_parameters() if param.requires_grad}
samples/1272-128104-0000.flac ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:4e25e22555cd16e90edb0a3b49fdcf1fe652b2a1250ab643634db33895c75b41
3
+ size 120041
samples/1272-128104-0001.flac ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:46a9d58622b4675b29564da2d9ba73e702241c5fa969f12c387cad4aa984276a
3
+ size 101672
samples/1272-128104-0002.flac ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:59bf7bb1129c11734a8dd362ad52b4e10ae31c03f0879fc92f7107ecd513513f
3
+ size 255853
samples/1272-128104-0003.flac ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:60d72657439cd3092fc2313efe0fabd31f0cd2f28f8ca46a21380c290b2e031d
3
+ size 196122
samples/samples.json ADDED
@@ -0,0 +1,18 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [
2
+ {
3
+ "file": "1272-128104-0000.flac",
4
+ "transcription": "mister quilter is the apostle of the middle classes and we are glad to welcome his gospel"
5
+ },
6
+ {
7
+ "file": "1272-128104-0001.flac",
8
+ "transcription": "nor is mister quilter's manner less interesting than his matter"
9
+ },
10
+ {
11
+ "file": "1272-128104-0002.flac",
12
+ "transcription": "he tells us that at this festive season of the year with christmas and roast beef looming before us similes drawn from eating and its results occur most readily to the mind"
13
+ },
14
+ {
15
+ "file": "1272-128104-0003.flac",
16
+ "transcription": "he has grave doubts whether sir frederick leighton's work is really greek after all and can discover in it but little of rocky ithaca"
17
+ }
18
+ ]
selfgen_mix/model.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a40967ad40705a5fb5bc08d8d50bc8eaa5a6ef05f2915a15e68c5d60428fa4a
3
+ size 146925138
selfgen_only/model.ckpt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fbea96fded8610bd5d66b5576c3d8c5635b2ba3e063896bd32c9ec562910c548
3
+ size 146924882