File size: 16,848 Bytes
0993d8a
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
#!/usr/bin/env python3
"""Sidon call-centre restoration — inference.

Restores telephony / call-centre audio (narrowband, codec'd, noisy) to clean
48 kHz, using the two trained stages:

  input audio --resample 16k--> (call-centre FE: 24L w2v-BERT + LoRA) --features[T,1024]-->
      (DAC decoder, 188M) --> 48 kHz waveform

The FE LoRA adapter is **merged into the base weights** here (W_eff = W + (alpha/r)*B@A,
bias = trained bias), so inference needs NO `peft` — just transformers + descript-audio-codec.

Usage:
  python runpod/infer_callcentre.py --input audio --out-dir audio/out \
      --fe-adapter checkpoints/fe_adapter_full.pt --decoder checkpoints/decoder_only.pt

Stereo inputs (e.g. agent/customer on separate channels) are restored per-channel
and recombined to stereo. A `<name>_orig<khz>.wav` (naive-upsampled input, no model)
is also written for an apples-to-apples A/B listen.

The output rate comes from the DECODER CHECKPOINT (`out_sr`/`dec_rates`), so a 24 kHz
enhancer-lite decoder needs no extra flag; 48 kHz models keep the `_restored48k.wav`
filenames the eval scripts already expect.
"""
from __future__ import annotations

import os

os.environ.setdefault("OMP_NUM_THREADS", "4")

import argparse
import glob
import time

import numpy as np
import soundfile as sf
import torch
import torchaudio
from transformers import AutoFeatureExtractor, Wav2Vec2BertModel

import dac

# tm-b300 (B300 = sm_103): torch 2.8's cuDNN has no tuned 1-D conv kernels there (32 TFLOP/s in every precision),
# so FAST_CONV1D=1 routes conv1d / conv_transpose1d through unfold + GEMM (runpod/fast_conv1d.py, verified
# against the reference to 1e-7). Off by default: other boxes keep cuDNN byte for byte.
if os.environ.get("FAST_CONV1D", "0") == "1":
    import fast_conv1d as _fc
    if _fc.patch():   # only on Blackwell-class GPUs; elsewhere cuDNN stays and nothing is printed
        print("[fast_conv1d] conv1d/conv_transpose1d via GEMM (sm_10x cuDNN workaround)", flush=True)


SSL_MODEL = "facebook/w2v-bert-2.0"
FE_SR = 16000
SR_OUT = 48000
AUDIO_EXTS = (".wav", ".flac", ".mp3", ".ogg", ".opus", ".m4a")


def log(m: str) -> None:
    print(m, flush=True)


def load_fe(adapter_path: str, device: torch.device) -> Wav2Vec2BertModel:
    """Build the 24L w2v-BERT base and merge the trained LoRA adapter into it."""
    ck = torch.load(adapter_path, map_location="cpu")
    ad = ck["adapter"]
    scaling = ck["lora_alpha"] / ck["r"]
    layers = ck.get("layers", 24)
    model = Wav2Vec2BertModel.from_pretrained(SSL_MODEL, num_hidden_layers=layers, layerdrop=0.0)
    sd = model.state_dict()
    prefixes = sorted({k[: -len(".lora_A.default.weight")]
                       for k in ad if k.endswith(".lora_A.default.weight")})
    merged = 0
    for p in prefixes:                       # p e.g. encoder.layers.0.ffn1.output_dense
        A = ad[p + ".lora_A.default.weight"].float()   # (r, in)
        B = ad[p + ".lora_B.default.weight"].float()   # (out, r)
        delta = scaling * (B @ A)                       # (out, in)
        wkey = p + ".weight"
        sd[wkey] = sd[wkey].float() + delta.to(sd[wkey].dtype)
        bkey = p + ".base_layer.bias"                   # trained (lora_only) bias
        if bkey in ad:
            sd[p + ".bias"] = ad[bkey].to(sd[p + ".bias"].dtype)
        merged += 1
    model.load_state_dict(sd)
    model.to(device).eval()
    for q in model.parameters():
        q.requires_grad_(False)
    log(f"[fe] merged LoRA into {merged} output_dense layers (scaling={scaling}); "
        f"step {ck.get('step')}")
    return model


def load_decoder(path: str, device: torch.device, input_channel: int = 1024):
    """Returns (decoder, out_sr). The graph is reconstructed from the checkpoint, so a
    24 kHz decoder (`rates=[8,5,4,3]`) loads without any CLI flag; checkpoints written
    before --out-sr existed have no `out_sr` key and were all 48 kHz."""
    ck = torch.load(path, map_location="cpu")
    ch = ck.get("dec_channels", 3072)
    extra = ck.get("extra_res", "")   # depth-expanded ckpts, see expand_decoder.py
    out_sr = int(ck.get("out_sr", SR_OUT))
    rates = ([int(r) for r in ck["dec_rates"].split(",")] if ck.get("dec_rates")
             else [8, 5, 4, 3, 2])
    # `dec_arch` only exists on ckpts written after the HiFi-GAN port; everything older
    # is a DAC decoder, so the default keeps every existing checkpoint loading unchanged.
    arch = ck.get("dec_arch", "dac")
    if arch == "hifigan":
        from hifigan_decoder import build_hifigan_decoder
        dec = build_hifigan_decoder(input_channel, ch, rates=tuple(rates),
                                    activation=ck.get("dec_activation", "lrelu"))
    else:
        from expand_decoder import build_decoder, parse_extra_res
        dec = build_decoder(input_channel, ch, parse_extra_res(extra), rates=rates)
    dec.load_state_dict(ck["decoder"])
    dec.to(device).eval()
    for q in dec.parameters():
        q.requires_grad_(False)
    label = "HiFi-GAN(MRF)" if arch == "hifigan" else "DAC"
    log(f"[dec] {label} decoder channels={ch}{' +' + extra if extra else ''} rates={rates} "
        f"-> {out_sr} Hz ({sum(p.numel() for p in dec.parameters())/1e6:.1f}M); "
        f"step {ck.get('step')}")
    return dec, out_sr


def _peak_norm(x: np.ndarray, peak: float = 0.95) -> np.ndarray:
    m = float(np.abs(x).max())
    return (x / m * peak).astype("float32") if m > 1e-6 else x.astype("float32")


def _lowpass(x: np.ndarray, sr: int, fc: float) -> np.ndarray:
    """Zero-phase low-pass of the decoder output (see --out-lowpass for the measurement
    that justifies the 8 kHz default). scipy is a hard dep of the training stack; if it
    is somehow absent, pass the audio through rather than fail an inference run."""
    try:
        from scipy.signal import butter, sosfiltfilt
    except Exception:  # noqa: BLE001
        log("[lp] scipy unavailable — skipping --out-lowpass")
        return x
    sos = butter(8, fc / (sr / 2), btype="low", output="sos")
    return sosfiltfilt(sos, x).astype("float32")


@torch.no_grad()
def restore_channel(wav16: np.ndarray, fe, dec, proc, device, chunk_s: float, bf16: bool,
                    out_sr: int = SR_OUT):
    """wav16: 1-D float32 @16k (peak-normalized) -> restored 1-D float32 @out_sr.
    chunk_s > 0 => WINDOWED. The DEFAULT IS ARCH-AWARE (resolved in main): 4.0 s for w2vbert,
    0 (straight pass) for distilhubert. Both are measured, and they differ because only one of
    the two feature extractors is length-dependent:
      w2vbert      SeamlessM4TFeatureExtractor normalises PER UTTERANCE -> the same audio
                   encodes differently depending on how much follows it (max|diff| 0 -> 10.4
                   from 6 s -> 29 s). Training used 6 s windows, so whole-file is OOD.
      distilhubert its INPUT features are bit-identical across length (do_normalize=False on
                   raw waveform), but its HIDDEN STATES are NOT -- the transformer attends over
                   the whole utterance, so the same 6 s drifts 18.9-39.5 % as more audio follows.
                   Window it too. (An earlier note here claimed invariance from the input test
                   alone; that inference was wrong.)

    The old docstring claimed a full pass is "length-invariant and cleaner" because w2v-BERT
    uses rotary embeddings and the decoder is convolutional. **That is wrong, measured
    2026-08-13.** `SeamlessM4TFeatureExtractor` normalises each utterance to zero-mean /
    unit-variance, so the SAME audio gets different features depending on how much audio
    follows it: on one 30 s clip the first 6 s came out as ~1.61x + 0.37 relative to encoding
    those 6 s alone. Training only ever used 6 s windows, so whole-file inference is
    out-of-distribution and the decoder collapses to silence on parts of long inputs.
    Measured on 20 worst-case clips: whole-pass 82.7 % of speech muted with 46 dropouts >3 s,
    vs 33.6 % / 20 at chunk=4. On a random 40 it is a wash (14.9 -> 14.3 %), so windowing
    rescues the pathological tail rather than improving the average -- but the tail is 20 s
    holes in customer calls. Sweep: 4 s best, then 6 / 8 / 12, monotone in length.
    COST: the 2 s overlap means chunk=4 is ~2x the compute of a straight pass (chunk=6 is
    ~1.5x) -- relevant for the CPU-realtime lite path, not for GPU."""
    n = len(wav16)
    win = int(chunk_s * FE_SR)
    if chunk_s <= 0 or n <= win:
        bounds = [(0, n)]
    else:
        ov = int(2.0 * FE_SR)                # 2 s overlap
        hop = win - ov
        bounds = [(s, min(s + win, n)) for s in range(0, n, hop)]
        bounds = [b for b in bounds if b[1] > b[0]]
        # A trailing sliver CRASHES the stack: the FE conv frontend strides 320, so <~0.5 s
        # yields 1-2 frames and the decoder's conv stack raises "Kernel size can't be greater
        # than actual input size". Latent until chunking became the default (it killed a
        # full-set run after 244/1289 clips). Absorb any short tail into the previous window
        # instead of emitting it — the overlap-add already covers that audio.
        MIN_SEG = int(0.6 * FE_SR)
        while len(bounds) > 1 and (bounds[-1][1] - bounds[-1][0]) < MIN_SEG:
            bounds[-2] = (bounds[-2][0], bounds[-1][1])
            bounds.pop()
    # 16k -> out_sr upsample ratio (x3 at 48 kHz, x1.5 at 24 kHz) — integer math only, so
    # a 24 kHz model doesn't silently round its overlap-add offsets.
    up_num, up_den = out_sr, FE_SR
    out = np.zeros(n * up_num // up_den + out_sr, dtype="float32")   # + pad slack
    wsum = np.zeros_like(out)
    amp = torch.bfloat16 if (bf16 and device.type == "cuda") else torch.float32
    for s, e in bounds:
        seg = np.pad(wav16[s:e], (40, 40))
        feats_in = proc(seg, sampling_rate=FE_SR, return_tensors="pt")
        feats_in = {k: v.to(device) for k, v in feats_in.items()}
        with torch.autocast(device.type, dtype=amp, enabled=(amp == torch.bfloat16)):
            h = fe(**feats_in).last_hidden_state          # [1,T,1024]
            y = dec(h.transpose(1, 2))                    # [1,1,L] @out_sr
        y = y.squeeze().float().cpu().numpy()
        o0 = s * up_num // up_den
        L = min(len(y), len(out) - o0)
        ramp = np.ones(L, dtype="float32")
        if len(bounds) > 1:                                # taper edges for crossfade
            r = min(int(2.0 * out_sr), L // 2)
            if r > 0:
                ramp[:r] = np.linspace(0, 1, r)
                ramp[-r:] = np.linspace(1, 0, r)
        out[o0:o0 + L] += y[:L] * ramp
        wsum[o0:o0 + L] += ramp
    valid = wsum > 1e-6
    out[valid] /= wsum[valid]
    return out[: n * up_num // up_den]


def main() -> None:
    ap = argparse.ArgumentParser()
    ap.add_argument("--input", default="audio", help="audio file or directory")
    ap.add_argument("--out-dir", default="audio/out")
    ap.add_argument("--fe-adapter", default="checkpoints/fe_adapter_full.pt",
                    help="w2vbert: LoRA adapter (merged into the base at load)")
    ap.add_argument("--decoder", default="checkpoints/decoder_only.pt")
    ap.add_argument("--fe-arch", default="w2vbert", choices=["w2vbert", "distilhubert"],
                    help="distilhubert = the ~37M realtime FE (enhancer-lite); pass the "
                         "stage-1 checkpoint via --fe-adapter (it holds the full FE)")
    ap.add_argument("--chunk", type=float, default=None,
                    help="window seconds, crossfaded with 2 s overlap. DEFAULT IS ARCH-AWARE: "
                         "4.0 for w2vbert (its SeamlessM4T fbank normalises PER UTTERANCE, so a "
                         "whole-file pass is out-of-distribution and drops speech), 0 for "
                         "distilhubert (raw waveform, do_normalize=False -> verified BIT-IDENTICAL "
                         "at 6/10/20/29 s, so windowing only costs ~2x compute). Pass a value to "
                         "override; 0 = single straight pass.")
    ap.add_argument("--mono", action="store_true", help="downmix to mono instead of per-channel")
    ap.add_argument("--out-lowpass", type=float, default=0.0,
                    help="low-pass the decoder output at this Hz (0 = off, the DEFAULT since 2026-10-03). From 2026-08-07 to "
                         "2026-10-03 the default was 8000: measured on the 58-clip EMGS benchmark as a small gain on both ASRs "
                         "(WER 56.18 vs 57.63 whisper-large-v3; 50.00 vs 51.77 Malaysian-whisper-turbo) and on UTMOS (2.230 vs "
                         "2.205). It also removed the whole 8-24 kHz band of a 48 kHz model, so every full-band measurement read "
                         "a 10 kHz ceiling that was this filter, not the decoder (ABLATION.md 2026-10-03). The user removed it: "
                         "the shipped output is the decoder's native band. Pass --out-lowpass 8000 to reproduce the old outputs.")
    ap.add_argument("--bf16", type=int, default=1)
    ap.add_argument("--device", default="cuda")
    a = ap.parse_args()
    dev = torch.device(a.device if torch.cuda.is_available() else "cpu")
    torch.set_float32_matmul_precision("medium")
    os.makedirs(a.out_dir, exist_ok=True)

    if os.path.isdir(a.input):
        files = sorted(f for f in glob.glob(os.path.join(a.input, "*"))
                       if f.lower().endswith(AUDIO_EXTS))
    else:
        files = [a.input]
    if not files:
        raise SystemExit(f"no audio under {a.input}")

    if a.fe_arch == "distilhubert":
        import fe_distilhubert as fedh
        fe = fedh.load_fe(a.fe_adapter, dev)
        proc = fedh.make_processor()          # raw waveform in (no fbank)
        dec, out_sr = load_decoder(a.decoder, dev, input_channel=fe.out_dim)
        # WINDOW THIS TOO. A 2026-08-14 conclusion that distilhubert is "length-invariant"
        # was WRONG: it measured the FEATURE EXTRACTOR (do_normalize=False on raw waveform ->
        # input features genuinely bit-identical at 6/10/20/29 s) and then wrongly inferred
        # the MODEL was invariant. The FE is a TRANSFORMER, so attention spans the whole
        # utterance: re-measured 2026-08-18, the hidden states for the SAME first 6 s drift
        # 18.9 % / 38.6 % / 39.5 % (mean rel err) when 4 / 14 / 23 s follow. Whole-pass
        # inference then produced 24 s dropouts and ~29k mute events on the 1,289-clip set,
        # vs 6.6k at chunk=4. Input invariance does NOT imply representation invariance.
        if a.chunk is None:
            a.chunk = 4.0
    else:
        fe = load_fe(a.fe_adapter, dev)
        dec, out_sr = load_decoder(a.decoder, dev)
        proc = AutoFeatureExtractor.from_pretrained(SSL_MODEL)
        # w2v-BERT: SeamlessM4TFeatureExtractor normalises PER UTTERANCE, so the same audio
        # encodes differently depending on how much follows it (max|diff| 0 -> 10.4 from
        # 6 s -> 29 s; ~1.61x + 0.37 affine). Training used 6 s windows, so a whole-file pass
        # is out-of-distribution. Window by default.
        if a.chunk is None:
            a.chunk = 4.0
    khz = f"{out_sr // 1000}k"                # filenames follow the model, not a constant

    for path in files:
        name = os.path.splitext(os.path.basename(path))[0]
        data, sr = sf.read(path, always_2d=True, dtype="float32")   # [N, C]
        if a.mono:
            data = data.mean(axis=1, keepdims=True)
        nch = data.shape[1]
        t0 = time.time()
        chans = []
        for c in range(nch):
            x = data[:, c]
            x16 = (torchaudio.functional.resample(torch.from_numpy(x)[None], sr, FE_SR)[0].numpy()
                   if sr != FE_SR else x)
            x16 = _peak_norm(x16, 0.95)
            yout = restore_channel(x16, fe, dec, proc, dev, a.chunk, bool(a.bf16), out_sr)
            if a.out_lowpass and a.out_lowpass < out_sr / 2:
                yout = _lowpass(yout, out_sr, a.out_lowpass)
            chans.append(_peak_norm(yout, 0.97))
        L = max(len(c) for c in chans)
        chans = [np.pad(c, (0, L - len(c))) for c in chans]
        restored = np.stack(chans, axis=1)                          # [L, C]

        # naive-upsampled input (no model) for an A/B reference, at the model's rate
        orig = torchaudio.functional.resample(torch.from_numpy(data.T), sr, out_sr).T.numpy()
        sf.write(os.path.join(a.out_dir, f"{name}_orig{khz}.wav"), _peak_norm(orig, 0.97), out_sr)
        sf.write(os.path.join(a.out_dir, f"{name}_restored{khz}.wav"), restored, out_sr)
        dur = data.shape[0] / sr
        log(f"[ok] {name}: {nch}ch {sr}Hz {dur:.1f}s -> {khz} in {time.time()-t0:.1f}s "
            f"(RTF {(time.time()-t0)/dur:.2f})")

    log(f"[done] outputs in {a.out_dir}")


if __name__ == "__main__":
    main()