ozonetg commited on
Commit
1d9e3e4
·
verified ·
1 Parent(s): 886f33e

AMD telephony model: v5b_full + benchmarked v4a, code, model card

Browse files
README.md ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ license: cc-by-nc-4.0
3
+ language:
4
+ - en
5
+ pipeline_tag: audio-classification
6
+ tags:
7
+ - audio
8
+ - telephony
9
+ - answering-machine-detection
10
+ - voicemail-detection
11
+ - yamnet
12
+ - gru
13
+ - 8khz
14
+ - vicidial
15
+ - asterisk
16
+ ---
17
+
18
+ # AMD — Answering Machine Detection for telephony (YAMNet + GRU)
19
+
20
+ Real-time answering-machine detection for 8 kHz US-English telephony
21
+ (VICIdial / Asterisk outbound campaigns). A pure-PyTorch YAMNet backbone
22
+ (fine-tuned, top-2 blocks) feeds a bidirectional GRU head; output is
23
+ `p_machine ∈ [0,1]`. **~16 ms inference per 4 s of audio on a single CPU
24
+ thread** — fast enough to decide inside the first ring seconds.
25
+
26
+ Two checkpoints:
27
+
28
+ | checkpoint | training pool | how it was measured |
29
+ |---|---|---|
30
+ | **`v5b_full` (root)** | 50,876 clips — the full max-data pool (incl. real carrier voicemail, IVR, network intercepts, robocalls, call-center + conversational telephone speech) | validation (4,949 held-out clips) |
31
+ | `benchmarked_v4a/` | 31,947 clips (frozen-eval-disjoint) | two frozen external test sets |
32
+
33
+ ## Results
34
+
35
+ `v5b_full` (validation, threshold 0.5): **97.7% accuracy**, machine recall
36
+ **97.0%** under a ≤3% false-machine-on-humans cap, fmh 1.56%. On held-out
37
+ clips of the hardest class — real carrier voicemail greetings — error is
38
+ **13%**, versus 91% for a naive-composition baseline.
39
+
40
+ `benchmarked_v4a` (frozen external sets, never trained on, one pass): 79.8%
41
+ accuracy / balanced-acc .845 / AUC .935 on a 796-clip external set;
42
+ 92.5% / .928 / .979 on a 1,429-clip balanced set; machine recall at the
43
+ FTC-style ≤3% abandonment cap: 0.56–0.91.
44
+
45
+ ## Usage
46
+
47
+ ```bash
48
+ pip install torch numpy soundfile scipy
49
+ python code/predict_example.py your_call.wav
50
+ ```
51
+
52
+ ```python
53
+ import sys; sys.path.insert(0, "code")
54
+ from predict_example import load, predict
55
+ yamnet, head = load()
56
+ p_machine = predict(yamnet, head, "call.wav") # 0..1
57
+ ```
58
+
59
+ Audio: any wav (resampled to 16 kHz mono internally; trained domain is 8 kHz
60
+ G.711 telephony, first ≤10 s of the call).
61
+
62
+ ## Intended use & limitations
63
+
64
+ - **Research / evaluation only (CC BY-NC 4.0).** Trained on a mixed-license
65
+ research pool; not cleared for commercial deployment.
66
+ - US-English telephony. Non-English and non-telephone audio are out of domain.
67
+ - Decision thresholds must be **calibrated on your own audio** — false-machine
68
+ rates measured on any fixed test set do not transfer exactly to live traffic.
69
+ - `v5b_full` has no fully-external benchmark (its pool absorbed the project's
70
+ test sources — by design, for maximum coverage); `benchmarked_v4a` is the
71
+ checkpoint with honest external numbers. Known weak spots: robocall recall
72
+ (v5 val slice: 26% miss), heavily accented/studio-recorded humans.
73
+
74
+ ## Architecture
75
+
76
+ - Backbone: YAMNet (AudioSet), pure-PyTorch port, custom `torch.stft` mel
77
+ frontend — no TensorFlow; top-2 blocks fine-tuned (BN frozen), bf16.
78
+ - Head: bidirectional GRU over 1024-d patch embeddings (0.96 s window /
79
+ 0.48 s hop), ~350k params, label smoothing 0.1.
80
+ - Files: `*.yamnet.pth` (backbone, 15 MB) + `*.head.pt` (head + config, 1 MB)
81
+ + `*.json` (export manifest with round-trip verification).
benchmarked_v4a/v4a_ftw_mfnone_pw05.head.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:fced3ef1ec2b9ad9c0717589341049751534040499aa2a9bcecc365b0107fd85
3
+ size 1051377
benchmarked_v4a/v4a_ftw_mfnone_pw05.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backbone_weights": "/root/amd/tracks/yamnet/experiments/backbone/v4a_ftw_mfnone_pw05.yamnet.pth",
3
+ "head_weights": "/root/amd/tracks/yamnet/experiments/backbone/v4a_ftw_mfnone_pw05.head.pt",
4
+ "head_config": {
5
+ "proj_dim": 128,
6
+ "hidden": 96,
7
+ "num_layers": 1,
8
+ "bidirectional": true,
9
+ "pooling": "last",
10
+ "dropout": 0.0
11
+ },
12
+ "gate_dbfs": -300.0,
13
+ "decision_threshold": 0.5,
14
+ "unfreeze_blocks": 2,
15
+ "note": "load backbone via extract_embeddings.load_model(backbone_weights); build head via sweep_head.build_config_head(head_config)."
16
+ }
benchmarked_v4a/v4a_ftw_mfnone_pw05.yamnet.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:3bdadf83b44b5318354aae774ae2d5ddec78aec21fe49205fb5135249b8814c8
3
+ size 15103509
code/extract_embeddings.py ADDED
@@ -0,0 +1,449 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """YAMNet 1024-d embedding extractor for the AMD Track-3 (YAMNet + GRU) recipe.
3
+
4
+ Provenance / port choice
5
+ -------------------------
6
+ Network + pretrained weights: w-hc/torch_audioset (pure-PyTorch YAMNet port,
7
+ numerically validated against Google's TF YAMNet to atol=1e-6 on the CNN given
8
+ identical mel patches).
9
+ - architecture ported verbatim from
10
+ https://github.com/w-hc/torch_audioset/blob/master/torch_audioset/yamnet/model.py
11
+ - weights: https://github.com/w-hc/torch_audioset/releases/download/v0.1/yamnet.pth
12
+ sha256 620b5feb079a97f7c884427f66cf9b281dca0725f46828eb019d01cacb7ae968
13
+ (3.76M params; local copy at tracks/yamnet/weights/yamnet.pth)
14
+
15
+ Why we DON'T use the upstream frontend / torch-vggish-yamnet pip:
16
+ both pull in `torchaudio` for the log-mel, and there is no torchaudio build
17
+ matching this box's torch 2.13.0+cpu (ABI risk). So the log-mel frontend here
18
+ is reimplemented with CORE torch (`torch.stft` + a hand-built HTK mel
19
+ filterbank) reproducing w-hc's exact YAMNetParams. Zero pip installs.
20
+ Exact bit-parity with TF is not required: the GRU head is trained on and
21
+ evaluated with THESE embeddings, so only self-consistency matters — but the
22
+ frontend still matches YAMNet's documented params closely so the pretrained
23
+ conv weights stay meaningful.
24
+
25
+ Output
26
+ ------
27
+ Per clip, one .npz with:
28
+ embeddings : float32 [n_frames, 1024] (0.96s window, 0.48s hop → ~1 vec/0.48s)
29
+ energy : float32 [n_frames] (dBFS of the raw 16k waveform slice
30
+ aligned to each patch; for the silence
31
+ gate, tuned LATER without re-extraction)
32
+ sr, n_frames scalars for provenance.
33
+
34
+ Usage
35
+ -----
36
+ python extract_embeddings.py --wav clip.wav --out-dir emb/
37
+ python extract_embeddings.py --manifest data/manifest.csv --out-dir emb/ --threads 8
38
+ python extract_embeddings.py --selftest # synthetic verify + benchmark
39
+ """
40
+ import argparse
41
+ import csv
42
+ import hashlib
43
+ import math
44
+ import os
45
+ import sys
46
+ import time
47
+
48
+ import numpy as np
49
+ import soundfile as sf
50
+ import torch
51
+ import torch.nn as nn
52
+ import torch.nn.functional as F
53
+ from scipy.signal import resample_poly
54
+
55
+ # ----------------------------------------------------------------------------
56
+ # YAMNet params (from w-hc CommonParams/YAMNetParams == TF yamnet/params.py)
57
+ # ----------------------------------------------------------------------------
58
+ SR = 16000
59
+ STFT_WIN_S = 0.025 # 400 samples
60
+ STFT_HOP_S = 0.010 # 160 samples
61
+ N_FFT = 512
62
+ N_MELS = 64
63
+ MEL_MIN_HZ = 125.0
64
+ MEL_MAX_HZ = 7500.0
65
+ LOG_OFFSET = 0.001
66
+ PATCH_WINDOW_S = 0.96 # 96 mel frames
67
+ PATCH_HOP_S = 0.48 # 48 mel frames -> one embedding every 0.48s
68
+ EMB_DIM = 1024
69
+
70
+ WIN_SAMPLES = int(round(SR * STFT_WIN_S)) # 400
71
+ HOP_SAMPLES = int(round(SR * STFT_HOP_S)) # 160
72
+ PATCH_FRAMES = int(round(PATCH_WINDOW_S / STFT_HOP_S)) # 96
73
+ PATCH_HOP_FRAMES = int(round(PATCH_HOP_S / STFT_HOP_S)) # 48
74
+
75
+ _WEIGHTS = os.path.join(os.path.dirname(os.path.abspath(__file__)),
76
+ "weights", "yamnet.pth")
77
+
78
+
79
+ # ----------------------------------------------------------------------------
80
+ # Network (ported verbatim from w-hc/torch_audioset yamnet/model.py)
81
+ # ----------------------------------------------------------------------------
82
+ class Conv2d_tf(nn.Conv2d):
83
+ """Conv2d with TF-Slim 'SAME' padding."""
84
+ def __init__(self, *args, **kwargs):
85
+ padding = kwargs.pop("padding", "SAME")
86
+ super().__init__(*args, **kwargs)
87
+ self.padding = padding
88
+ assert self.padding == "SAME"
89
+
90
+ def _same_pad(self, input, dim):
91
+ input_size = input.size(dim + 2)
92
+ filter_size = self.kernel_size[dim]
93
+ dilate = self.dilation if isinstance(self.dilation, int) else self.dilation[dim]
94
+ stride = self.stride if isinstance(self.stride, int) else self.stride[dim]
95
+ eff = (filter_size - 1) * dilate + 1
96
+ out_size = (input_size + stride - 1) // stride
97
+ total = max(0, (out_size - 1) * stride + eff - input_size)
98
+ return int(total % 2 != 0), total
99
+
100
+ def forward(self, input):
101
+ odd_1, pad_1 = self._same_pad(input, dim=0)
102
+ odd_2, pad_2 = self._same_pad(input, dim=1)
103
+ if odd_1 or odd_2:
104
+ input = F.pad(input, [0, odd_2, 0, odd_1])
105
+ return F.conv2d(input, self.weight, self.bias, self.stride,
106
+ padding=[pad_1 // 2, pad_2 // 2],
107
+ dilation=self.dilation, groups=self.groups)
108
+
109
+
110
+ class CONV_BN_RELU(nn.Module):
111
+ def __init__(self, conv):
112
+ super().__init__()
113
+ self.conv = conv
114
+ self.bn = nn.BatchNorm2d(conv.out_channels, eps=1e-4)
115
+ self.relu = nn.ReLU()
116
+
117
+ def forward(self, x):
118
+ return self.relu(self.bn(self.conv(x)))
119
+
120
+
121
+ class Conv(nn.Module):
122
+ def __init__(self, kernel, stride, input_dim, output_dim):
123
+ super().__init__()
124
+ self.fused = CONV_BN_RELU(Conv2d_tf(
125
+ in_channels=input_dim, out_channels=output_dim, kernel_size=kernel,
126
+ stride=stride, padding="SAME", bias=False))
127
+
128
+ def forward(self, x):
129
+ return self.fused(x)
130
+
131
+
132
+ class SeparableConv(nn.Module):
133
+ def __init__(self, kernel, stride, input_dim, output_dim):
134
+ super().__init__()
135
+ self.depthwise_conv = CONV_BN_RELU(Conv2d_tf(
136
+ in_channels=input_dim, out_channels=input_dim, groups=input_dim,
137
+ kernel_size=kernel, stride=stride, padding="SAME", bias=False))
138
+ self.pointwise_conv = CONV_BN_RELU(Conv2d_tf(
139
+ in_channels=input_dim, out_channels=output_dim, kernel_size=1,
140
+ stride=1, padding="SAME", bias=False))
141
+
142
+ def forward(self, x):
143
+ return self.pointwise_conv(self.depthwise_conv(x))
144
+
145
+
146
+ class YAMNet(nn.Module):
147
+ _CFG = [
148
+ (Conv, [3, 3], 2, 32), (SeparableConv, [3, 3], 1, 64),
149
+ (SeparableConv, [3, 3], 2, 128), (SeparableConv, [3, 3], 1, 128),
150
+ (SeparableConv, [3, 3], 2, 256), (SeparableConv, [3, 3], 1, 256),
151
+ (SeparableConv, [3, 3], 2, 512), (SeparableConv, [3, 3], 1, 512),
152
+ (SeparableConv, [3, 3], 1, 512), (SeparableConv, [3, 3], 1, 512),
153
+ (SeparableConv, [3, 3], 1, 512), (SeparableConv, [3, 3], 1, 512),
154
+ (SeparableConv, [3, 3], 2, 1024), (SeparableConv, [3, 3], 1, 1024),
155
+ ]
156
+
157
+ def __init__(self):
158
+ super().__init__()
159
+ input_dim = 1
160
+ self.layer_names = []
161
+ for i, (mod, kernel, stride, out_dim) in enumerate(self._CFG):
162
+ name = f"layer{i + 1}"
163
+ self.add_module(name, mod(kernel, stride, input_dim, out_dim))
164
+ input_dim = out_dim
165
+ self.layer_names.append(name)
166
+ self.classifier = nn.Linear(input_dim, 521, bias=True) # unused for emb
167
+
168
+ def embeddings(self, x):
169
+ """x: [N, 1, 96, 64] mel patches -> [N, 1024] pooled embeddings."""
170
+ for name in self.layer_names:
171
+ x = getattr(self, name)(x)
172
+ x = F.adaptive_avg_pool2d(x, 1).reshape(x.shape[0], -1)
173
+ return x
174
+
175
+
176
+ # ----------------------------------------------------------------------------
177
+ # Log-mel frontend (core torch; reproduces w-hc VGGishLogMelSpectrogram config)
178
+ # ----------------------------------------------------------------------------
179
+ def _htk_mel_fb():
180
+ """HTK triangular mel filterbank [n_freqs, n_mels], torchaudio norm=None."""
181
+ n_freqs = N_FFT // 2 + 1 # 257
182
+ all_freqs = torch.linspace(0, SR // 2, n_freqs)
183
+ m_min = 2595.0 * math.log10(1.0 + MEL_MIN_HZ / 700.0)
184
+ m_max = 2595.0 * math.log10(1.0 + MEL_MAX_HZ / 700.0)
185
+ m_pts = torch.linspace(m_min, m_max, N_MELS + 2)
186
+ f_pts = 700.0 * (10.0 ** (m_pts / 2595.0) - 1.0)
187
+ f_diff = f_pts[1:] - f_pts[:-1] # [n_mels+1]
188
+ slopes = f_pts.unsqueeze(0) - all_freqs.unsqueeze(1) # [n_freqs, n_mels+2]
189
+ down = -slopes[:, :-2] / f_diff[:-1]
190
+ up = slopes[:, 2:] / f_diff[1:]
191
+ return torch.clamp(torch.minimum(down, up), min=0.0) # [n_freqs, n_mels]
192
+
193
+
194
+ _MEL_FB = _htk_mel_fb()
195
+ _WINDOW = torch.hann_window(WIN_SAMPLES, periodic=True)
196
+
197
+
198
+ _MIN_SAMPLES = PATCH_FRAMES * HOP_SAMPLES # 15360 = 0.96s -> guarantees >=1 patch
199
+
200
+
201
+ def waveform_to_patches(wav16k):
202
+ """wav16k: 1-D torch float32 @16k -> mel patches [n_patches, 1, 96, 64].
203
+
204
+ Clips shorter than one 0.96s window are zero-padded up to it (paper-consistent
205
+ zero-padding) so every clip yields at least one frame instead of being dropped.
206
+ """
207
+ if wav16k.shape[0] < _MIN_SAMPLES:
208
+ wav16k = torch.nn.functional.pad(wav16k, (0, _MIN_SAMPLES - wav16k.shape[0]))
209
+ stft = torch.stft(wav16k, n_fft=N_FFT, hop_length=HOP_SAMPLES,
210
+ win_length=WIN_SAMPLES, window=_WINDOW, center=True,
211
+ pad_mode="reflect", return_complex=True, normalized=False)
212
+ mag = stft.abs() # [n_freqs, T] (power 2 -> sqrt = magnitude)
213
+ mel = torch.matmul(mag.transpose(0, 1), _MEL_FB) # [T, n_mels]
214
+ logmel = torch.log(mel + LOG_OFFSET) # [T, 64]
215
+ T = logmel.shape[0]
216
+ n_patches = (T - PATCH_FRAMES) // PATCH_HOP_FRAMES + 1
217
+ if n_patches < 1:
218
+ return torch.empty(0, 1, PATCH_FRAMES, N_MELS)
219
+ idx = torch.arange(PATCH_FRAMES).unsqueeze(0) + \
220
+ (torch.arange(n_patches) * PATCH_HOP_FRAMES).unsqueeze(1) # [n_patches, 96]
221
+ patches = logmel[idx] # [n_patches, 96, 64]
222
+ return patches.unsqueeze(1) # [n_patches, 1, 96, 64]
223
+
224
+
225
+ def patch_energy_dbfs(wav16k, n_patches):
226
+ """Per-patch dBFS of the raw 16k waveform slice aligned to each mel patch."""
227
+ win = int(round(PATCH_WINDOW_S * SR)) # 15360
228
+ hop = int(round(PATCH_HOP_S * SR)) # 7680
229
+ x = wav16k.detach().cpu().numpy()
230
+ out = np.empty(n_patches, dtype=np.float32)
231
+ for i in range(n_patches):
232
+ seg = x[i * hop: i * hop + win]
233
+ rms = float(np.sqrt(np.mean(seg.astype(np.float64) ** 2))) if seg.size else 0.0
234
+ out[i] = 20.0 * math.log10(rms + 1e-10) # dBFS, full-scale ref = 1.0
235
+ return out
236
+
237
+
238
+ # ----------------------------------------------------------------------------
239
+ # Model load + audio load
240
+ # ----------------------------------------------------------------------------
241
+ def load_model(weights=_WEIGHTS):
242
+ model = YAMNet()
243
+ sd = torch.load(weights, map_location="cpu", weights_only=True)
244
+ model.load_state_dict(sd)
245
+ model.eval()
246
+ return model
247
+
248
+
249
+ def load_audio_16k(path):
250
+ """Load any wav -> mono float32 @16k (scipy resample_poly for telephony 8k)."""
251
+ audio, sr = sf.read(path, dtype="float32", always_2d=False)
252
+ if audio.ndim > 1:
253
+ audio = audio.mean(axis=1)
254
+ if sr != SR:
255
+ g = math.gcd(int(sr), SR)
256
+ audio = resample_poly(audio, SR // g, int(sr) // g).astype(np.float32)
257
+ return torch.from_numpy(np.ascontiguousarray(audio, dtype=np.float32))
258
+
259
+
260
+ @torch.no_grad()
261
+ def extract_one(model, path):
262
+ wav = load_audio_16k(path)
263
+ patches = waveform_to_patches(wav)
264
+ n = patches.shape[0]
265
+ if n == 0:
266
+ return np.zeros((0, EMB_DIM), np.float32), np.zeros((0,), np.float32)
267
+ emb = model.embeddings(patches).numpy().astype(np.float32) # [n,1024]
268
+ energy = patch_energy_dbfs(wav, n)
269
+ return emb, energy
270
+
271
+
272
+ # ----------------------------------------------------------------------------
273
+ # CLI
274
+ # ----------------------------------------------------------------------------
275
+ def _safe_stem(path, idx):
276
+ stem = os.path.splitext(os.path.basename(path))[0]
277
+ return f"{idx:06d}_{stem}.npz"
278
+
279
+
280
+ def _cache_name(path):
281
+ """Stable per-clip cache filename = sha1(abspath). Lets the cache survive
282
+ manifest changes (row reorder / add / drop) so a later index rebuild can
283
+ reuse .npz by path, and --skip-existing can no-op unchanged clips."""
284
+ return hashlib.sha1(os.path.abspath(path).encode()).hexdigest()[:16] + ".npz"
285
+
286
+
287
+ def run_manifest(model, manifest, out_dir, name_by_hash=False, skip_existing=False):
288
+ os.makedirs(out_dir, exist_ok=True)
289
+ rows = []
290
+ with open(manifest, newline="") as f:
291
+ reader = csv.DictReader(f)
292
+ if "path" not in reader.fieldnames:
293
+ sys.exit(f"manifest {manifest} has no 'path' column: {reader.fieldnames}")
294
+ entries = list(reader)
295
+ t0 = time.perf_counter()
296
+ n_reused = n_new = 0
297
+ for i, row in enumerate(entries):
298
+ path = row["path"]
299
+ out_name = _cache_name(path) if name_by_hash else _safe_stem(path, i)
300
+ out_path = os.path.join(out_dir, out_name)
301
+ # reuse cache only if it exists AND the source file is not newer than it
302
+ # (a trimmed/replaced clip at the same path has a newer mtime -> re-extract)
303
+ reuse = (skip_existing and os.path.exists(out_path) and os.path.exists(path)
304
+ and os.path.getmtime(path) <= os.path.getmtime(out_path))
305
+ if reuse:
306
+ try:
307
+ n_frames = int(np.load(out_path)["n_frames"])
308
+ except Exception:
309
+ os.remove(out_path)
310
+ else:
311
+ n_reused += 1
312
+ rows.append({"npz": out_name, "path": path,
313
+ "label": row.get("label", ""),
314
+ "label_binary": row.get("label_binary", ""),
315
+ "source": row.get("source", ""), "n_frames": n_frames})
316
+ if (i + 1) % 500 == 0:
317
+ print(f" {i + 1}/{len(entries)} (reused {n_reused}, new {n_new})...")
318
+ continue
319
+ try:
320
+ emb, energy = extract_one(model, path)
321
+ except Exception as e:
322
+ print(f" [WARN] row {i} {path}: {type(e).__name__}: {e}")
323
+ continue
324
+ np.savez(out_path, embeddings=emb, energy=energy, sr=SR,
325
+ n_frames=emb.shape[0])
326
+ n_new += 1
327
+ rows.append({"npz": out_name, "path": path,
328
+ "label": row.get("label", ""),
329
+ "label_binary": row.get("label_binary", ""),
330
+ "source": row.get("source", ""),
331
+ "n_frames": emb.shape[0]})
332
+ if (i + 1) % 500 == 0:
333
+ print(f" {i + 1}/{len(entries)} (reused {n_reused}, new {n_new})...")
334
+ print(f" extracted new={n_new} reused={n_reused}")
335
+ idx_csv = os.path.join(out_dir, "embeddings_index.csv")
336
+ with open(idx_csv, "w", newline="") as f:
337
+ w = csv.DictWriter(
338
+ f, fieldnames=["npz", "path", "label", "label_binary", "source", "n_frames"])
339
+ w.writeheader()
340
+ w.writerows(rows)
341
+ dt = time.perf_counter() - t0
342
+ print(f"Done: {len(rows)}/{len(entries)} clips in {dt:.1f}s -> {out_dir}")
343
+ print(f"Index: {idx_csv}")
344
+
345
+
346
+ def run_single(model, wav, out_dir):
347
+ os.makedirs(out_dir, exist_ok=True)
348
+ emb, energy = extract_one(model, wav)
349
+ out_path = os.path.join(out_dir, _safe_stem(wav, 0))
350
+ np.savez(out_path, embeddings=emb, energy=energy, sr=SR, n_frames=emb.shape[0])
351
+ print(f"{wav}: embeddings {emb.shape}, energy {energy.shape} -> {out_path}")
352
+ print(f" energy dBFS: {np.array2string(energy, precision=1, max_line_width=200)}")
353
+
354
+
355
+ def selftest():
356
+ print("=== YAMNet extractor self-test ===")
357
+ model = load_model()
358
+ # (a) synthetic 8kHz input: 4s 440Hz tone, then 4s silence
359
+ sr_in = 8000
360
+ t = np.arange(4 * sr_in) / sr_in
361
+ tone = (0.5 * np.sin(2 * np.pi * 440 * t)).astype(np.float32)
362
+ silence = np.zeros(4 * sr_in, dtype=np.float32)
363
+ for name, sig in [("tone_4s@8k", tone), ("silence_4s@8k", silence)]:
364
+ wav = load_audio_16k_from_array(sig, sr_in)
365
+ patches = waveform_to_patches(wav)
366
+ with torch.no_grad():
367
+ emb = model.embeddings(patches).numpy()
368
+ energy = patch_energy_dbfs(wav, patches.shape[0])
369
+ finite = np.isfinite(emb).all()
370
+ const = bool(np.allclose(emb.std(axis=0), 0, atol=1e-4)) if emb.shape[0] > 1 else None
371
+ print(f"[{name}] patches={patches.shape[0]} emb={emb.shape} "
372
+ f"finite={finite} nan={np.isnan(emb).any()} "
373
+ f"emb_mean={emb.mean():.4f} emb_std={emb.std():.4f} "
374
+ f"all_frames_constant={const}")
375
+ print(f" energy dBFS = {np.array2string(energy, precision=1)}")
376
+ # combined tone-vs-silence separation check
377
+ tone_wav = load_audio_16k_from_array(tone, sr_in)
378
+ sil_wav = load_audio_16k_from_array(silence, sr_in)
379
+ with torch.no_grad():
380
+ e_tone = model.embeddings(waveform_to_patches(tone_wav)).numpy()
381
+ e_sil = model.embeddings(waveform_to_patches(sil_wav)).numpy()
382
+ diff = np.linalg.norm(e_tone.mean(0) - e_sil.mean(0))
383
+ en_tone = patch_energy_dbfs(tone_wav, waveform_to_patches(tone_wav).shape[0])
384
+ en_sil = patch_energy_dbfs(sil_wav, waveform_to_patches(sil_wav).shape[0])
385
+ print(f"[separation] ||emb_tone - emb_silence|| = {diff:.3f} (expect >> 0)")
386
+ print(f"[energy-gate] tone median dBFS={np.median(en_tone):.1f} vs "
387
+ f"silence median dBFS={np.median(en_sil):.1f} "
388
+ f"(gap={np.median(en_tone) - np.median(en_sil):.1f} dB)")
389
+ # (c) benchmark: 4s clip @8k input
390
+ bench_sig = tone
391
+ for th in [1, 8]:
392
+ torch.set_num_threads(th)
393
+ w = load_audio_16k_from_array(bench_sig, sr_in)
394
+ with torch.no_grad():
395
+ model.embeddings(waveform_to_patches(w)) # warmup
396
+ ts = []
397
+ for _ in range(10):
398
+ t0 = time.perf_counter()
399
+ p = waveform_to_patches(w)
400
+ model.embeddings(p)
401
+ patch_energy_dbfs(w, p.shape[0])
402
+ ts.append((time.perf_counter() - t0) * 1000)
403
+ ts.sort()
404
+ p50 = ts[len(ts) // 2]
405
+ rtf = 4000.0 / p50
406
+ print(f"[bench] threads={th}: {p50:.1f} ms / 4s clip "
407
+ f"(audio-sec per wall-sec = {rtf:.1f}x)")
408
+
409
+
410
+ def load_audio_16k_from_array(sig, sr_in):
411
+ if sr_in != SR:
412
+ g = math.gcd(int(sr_in), SR)
413
+ sig = resample_poly(sig, SR // g, int(sr_in) // g).astype(np.float32)
414
+ return torch.from_numpy(np.ascontiguousarray(sig, dtype=np.float32))
415
+
416
+
417
+ def main():
418
+ ap = argparse.ArgumentParser(description="YAMNet 1024-d embedding extractor")
419
+ ap.add_argument("--wav", help="single audio file")
420
+ ap.add_argument("--manifest", help="CSV with a 'path' column (+ optional label)")
421
+ ap.add_argument("--out-dir", default="embeddings", help="output dir for .npz")
422
+ ap.add_argument("--weights", default=_WEIGHTS)
423
+ ap.add_argument("--threads", type=int, default=0,
424
+ help="torch CPU threads (0 = leave default)")
425
+ ap.add_argument("--name-by-hash", action="store_true",
426
+ help="name .npz by sha1(abspath) (stable cache across manifest edits)")
427
+ ap.add_argument("--skip-existing", action="store_true",
428
+ help="reuse an existing .npz instead of re-extracting")
429
+ ap.add_argument("--selftest", action="store_true")
430
+ args = ap.parse_args()
431
+
432
+ if args.threads > 0:
433
+ torch.set_num_threads(args.threads)
434
+
435
+ if args.selftest:
436
+ selftest()
437
+ return
438
+ model = load_model(args.weights)
439
+ if args.manifest:
440
+ run_manifest(model, args.manifest, args.out_dir,
441
+ name_by_hash=args.name_by_hash, skip_existing=args.skip_existing)
442
+ elif args.wav:
443
+ run_single(model, args.wav, args.out_dir)
444
+ else:
445
+ sys.exit("provide --wav, --manifest, or --selftest")
446
+
447
+
448
+ if __name__ == "__main__":
449
+ main()
code/predict_example.py ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """Standalone inference for the Mission-3 AMD model (track3v4_a).
3
+
4
+ Usage:
5
+ python3 predict_example.py <audio.wav> [threshold]
6
+
7
+ Deps: pip install torch numpy soundfile scipy
8
+ Any wav works (8k mu-law telephony audio is the trained domain; anything else
9
+ is resampled to 16k mono internally). Default threshold 0.5; the FTC-compliance
10
+ calibration starting point is 0.876 — see docs/REPORT_M3.md section 7 before
11
+ trusting any fixed threshold.
12
+ """
13
+ import os
14
+ import sys
15
+
16
+ import torch
17
+
18
+ HERE = os.path.dirname(os.path.abspath(__file__))
19
+ sys.path.insert(0, HERE)
20
+ import extract_embeddings as xe # noqa: E402
21
+ from sweep_head import build_config_head # noqa: E402
22
+
23
+ MODEL_DIR = os.path.join(HERE, "..")
24
+
25
+
26
+ def load():
27
+ yamnet = xe.load_model(os.path.join(MODEL_DIR, "v5b_full.yamnet.pth"))
28
+ ck = torch.load(os.path.join(MODEL_DIR, "v5b_full.head.pt"),
29
+ map_location="cpu", weights_only=False)
30
+ head = build_config_head(ck["config"])
31
+ head.load_state_dict(ck["state_dict"])
32
+ head.eval()
33
+ return yamnet, head
34
+
35
+
36
+ def predict(yamnet, head, wav_path):
37
+ """Returns p_machine in [0,1]."""
38
+ wav = xe.load_audio_16k(wav_path)
39
+ patches = xe.waveform_to_patches(wav)
40
+ if patches.shape[0] == 0:
41
+ return 0.0 # no audio frames -> fail-open as human
42
+ with torch.no_grad():
43
+ emb = yamnet.embeddings(patches) # [n, 1024]
44
+ x = emb.unsqueeze(0) # [1, n, 1024]
45
+ lengths = torch.tensor([emb.shape[0]], dtype=torch.long)
46
+ return float(torch.sigmoid(head(x, lengths)).item())
47
+
48
+
49
+ if __name__ == "__main__":
50
+ if len(sys.argv) < 2:
51
+ sys.exit(__doc__)
52
+ torch.set_num_threads(1)
53
+ t = float(sys.argv[2]) if len(sys.argv) > 2 else 0.5
54
+ yamnet, head = load()
55
+ p = predict(yamnet, head, sys.argv[1])
56
+ print(f"p_machine={p:.4f} -> {'MACHINE' if p >= t else 'HUMAN'} (threshold {t})")
code/sweep_head.py ADDED
@@ -0,0 +1,124 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Configurable GRU classifier head for YAMNet embeddings (Mission-2 sweep).
2
+
3
+ Superset of gru_head.GRUHead: keeps the exact same math when built with the
4
+ canonical config (proj=32, hidden=48, layers=1, unidirectional, pooling='last',
5
+ dropout=0) but exposes every knob the round-1 grid needs:
6
+
7
+ proj_dim, hidden, num_layers, bidirectional, pooling {last|mean|attn}, dropout
8
+
9
+ `forward` returns a RAW LOGIT (no sigmoid), like gru_head, so training uses
10
+ BCEWithLogitsLoss for numerical stability. This module is NEW; it does not touch
11
+ gru_head.py / head_best.pt / the deployed adapter.
12
+
13
+ Pooling semantics (over the GRU output sequence, honoring per-clip lengths):
14
+ last : final hidden state h_n. Bidirectional -> concat last-layer fwd+bwd.
15
+ mean : mean of GRU outputs over valid timesteps.
16
+ attn : additive-attention weighted sum of GRU outputs over valid timesteps.
17
+ """
18
+ import torch
19
+ import torch.nn as nn
20
+ from torch.nn.utils.rnn import pack_padded_sequence, pad_packed_sequence
21
+
22
+ EMB_DIM = 1024
23
+
24
+
25
+ class ConfigGRUHead(nn.Module):
26
+ def __init__(self, emb_dim=EMB_DIM, proj=32, hidden=48, num_layers=1,
27
+ bidirectional=False, pooling="last", dropout=0.0):
28
+ super().__init__()
29
+ assert pooling in ("last", "mean", "attn"), pooling
30
+ self.pooling = pooling
31
+ self.bidirectional = bool(bidirectional)
32
+ self.num_layers = int(num_layers)
33
+ self.hidden = int(hidden)
34
+
35
+ self.proj = nn.Linear(emb_dim, proj) # Dense(1024->proj), tanh below
36
+ self.in_drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
37
+ gru_drop = dropout if (num_layers > 1 and dropout > 0) else 0.0
38
+ self.gru = nn.GRU(proj, hidden, num_layers=num_layers, batch_first=True,
39
+ bidirectional=self.bidirectional, dropout=gru_drop)
40
+
41
+ dirs = 2 if self.bidirectional else 1
42
+ self.feat_dim = hidden * dirs
43
+ if pooling == "attn":
44
+ self.attn_w = nn.Linear(self.feat_dim, self.feat_dim)
45
+ self.attn_v = nn.Linear(self.feat_dim, 1, bias=False)
46
+ self.out_drop = nn.Dropout(dropout) if dropout > 0 else nn.Identity()
47
+ self.out = nn.Linear(self.feat_dim, 1) # Dense(feat->1)
48
+
49
+ # -- pooling helpers -----------------------------------------------------
50
+ def _last(self, h_n):
51
+ """Final hidden state -> [B, feat_dim]. h_n: [layers*dirs, B, hidden]."""
52
+ if self.bidirectional:
53
+ fwd = h_n[-2] # last layer forward
54
+ bwd = h_n[-1] # last layer backward
55
+ return torch.cat([fwd, bwd], dim=-1)
56
+ return h_n[-1]
57
+
58
+ def _masked_seq(self, packed_out, lengths, batch_first_shape):
59
+ """Unpack -> (out [B,Tmax,feat], mask [B,Tmax] bool)."""
60
+ out, _ = pad_packed_sequence(packed_out, batch_first=True) # [B,Tmax,feat]
61
+ B, Tmax = out.shape[0], out.shape[1]
62
+ idx = torch.arange(Tmax, device=out.device).unsqueeze(0) # [1,Tmax]
63
+ mask = idx < lengths.to(out.device).unsqueeze(1) # [B,Tmax]
64
+ return out, mask
65
+
66
+ def forward(self, x, lengths):
67
+ """x: [B, T, emb_dim] padded. lengths: [B] valid frame counts. -> logit [B]."""
68
+ h = torch.tanh(self.proj(x))
69
+ h = self.in_drop(h)
70
+ packed = pack_padded_sequence(h, lengths.cpu(), batch_first=True,
71
+ enforce_sorted=False)
72
+ packed_out, h_n = self.gru(packed)
73
+
74
+ if self.pooling == "last":
75
+ feat = self._last(h_n) # [B, feat]
76
+ else:
77
+ out, mask = self._masked_seq(packed_out, lengths, x.shape)
78
+ m = mask.unsqueeze(-1).float() # [B,Tmax,1]
79
+ if self.pooling == "mean":
80
+ feat = (out * m).sum(1) / m.sum(1).clamp(min=1.0)
81
+ else: # attn
82
+ score = self.attn_v(torch.tanh(self.attn_w(out))).squeeze(-1) # [B,Tmax]
83
+ score = score.masked_fill(~mask, float("-inf"))
84
+ w = torch.softmax(score, dim=1).unsqueeze(-1) # [B,Tmax,1]
85
+ feat = (out * w).sum(1)
86
+
87
+ feat = self.out_drop(feat)
88
+ return self.out(feat).squeeze(-1)
89
+
90
+
91
+ def build_config_head(config):
92
+ """Construct a ConfigGRUHead from a config dict (arch keys only)."""
93
+ return ConfigGRUHead(
94
+ emb_dim=EMB_DIM,
95
+ proj=int(config.get("proj_dim", 32)),
96
+ hidden=int(config.get("hidden", 48)),
97
+ num_layers=int(config.get("num_layers", 1)),
98
+ bidirectional=bool(config.get("bidirectional", False)),
99
+ pooling=config.get("pooling", "last"),
100
+ dropout=float(config.get("dropout", 0.0)),
101
+ )
102
+
103
+
104
+ def param_count(model):
105
+ return sum(p.numel() for p in model.parameters())
106
+
107
+
108
+ if __name__ == "__main__":
109
+ # sanity: canonical config must match the 44,657-param deployed head math
110
+ from gru_head import PARAM_COUNT
111
+ m = build_config_head({"proj_dim": 32, "hidden": 48, "num_layers": 1,
112
+ "bidirectional": False, "pooling": "last", "dropout": 0.0})
113
+ n = param_count(m)
114
+ print(f"canonical ConfigGRUHead params = {n} (expected {PARAM_COUNT}): {n == PARAM_COUNT}")
115
+ x = torch.randn(3, 5, EMB_DIM)
116
+ lens = torch.tensor([5, 3, 1])
117
+ for pool in ("last", "mean", "attn"):
118
+ for bi in (False, True):
119
+ for nl in (1, 2):
120
+ mm = build_config_head({"proj_dim": 64, "hidden": 96, "num_layers": nl,
121
+ "bidirectional": bi, "pooling": pool, "dropout": 0.2})
122
+ y = mm(x, lens)
123
+ assert y.shape == (3,), (pool, bi, nl, y.shape)
124
+ print("forward shapes OK across pooling x bidir x layers")
v5b_full.head.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:b1eb0b111fd1919f419e2eea134964a8f3fa9b311976441d312a83373a653368
3
+ size 1051115
v5b_full.json ADDED
@@ -0,0 +1,16 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "backbone_weights": "/root/amd/tracks/yamnet/experiments/backbone/v5b_full.yamnet.pth",
3
+ "head_weights": "/root/amd/tracks/yamnet/experiments/backbone/v5b_full.head.pt",
4
+ "head_config": {
5
+ "proj_dim": 128,
6
+ "hidden": 96,
7
+ "num_layers": 1,
8
+ "bidirectional": true,
9
+ "pooling": "last",
10
+ "dropout": 0.0
11
+ },
12
+ "gate_dbfs": -300.0,
13
+ "decision_threshold": 0.5,
14
+ "unfreeze_blocks": 2,
15
+ "note": "load backbone via extract_embeddings.load_model(backbone_weights); build head via sweep_head.build_config_head(head_config)."
16
+ }
v5b_full.yamnet.pth ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:da4037dc39907021b8ac44e491a763defad1b4ff67532607fab9f86563a20cde
3
+ size 15101511