JointRecAlign 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1 @@
1
+ """Joint phone recognition and alignment with wav2vec2 (CTC + cross-attention aligner)."""
@@ -0,0 +1,4 @@
1
+ """`python -m jointrecalign`: same as the JointRecAlign command."""
2
+ from .transcribe_cli import main
3
+
4
+ main()
@@ -0,0 +1,82 @@
1
+ """The released model: downloaded once, on first use, into a cache folder.
2
+
3
+ Linux ~/.cache/JointRecAlign
4
+ macOS ~/Library/Caches/JointRecAlign
5
+ Windows %LOCALAPPDATA%\\JointRecAlign
6
+
7
+ JOINTRECALIGN_HOME overrides the folder; JOINTRECALIGN_MODEL_URL the download URL.
8
+ """
9
+ import hashlib
10
+ import os
11
+ import shutil
12
+ import sys
13
+ import urllib.request
14
+ import zipfile
15
+
16
+ MODEL_NAME = "aligner_10ms_sil_alpha0.1"
17
+ MODEL_URL = ("https://github.com/Imenbaa/Joint_phoneme_recognition_alignment/releases/download/"
18
+ f"v0.1.0/{MODEL_NAME}.zip")
19
+ MODEL_SHA256 = "a1afc8f77544475e062b9de252abfe2d1b9f10c79f5b9f4e702c57548ee67f7a"
20
+
21
+
22
+ def cache_dir():
23
+ if os.environ.get("JOINTRECALIGN_HOME"):
24
+ return os.environ["JOINTRECALIGN_HOME"]
25
+ if sys.platform == "win32":
26
+ base = os.environ.get("LOCALAPPDATA", os.path.expanduser("~"))
27
+ elif sys.platform == "darwin":
28
+ base = os.path.expanduser("~/Library/Caches")
29
+ else:
30
+ base = os.environ.get("XDG_CACHE_HOME", os.path.expanduser("~/.cache"))
31
+ return os.path.join(base, "JointRecAlign")
32
+
33
+
34
+ def _fetch(url, dest, log):
35
+ """Download url to dest, printing the progress every 5 %."""
36
+ with urllib.request.urlopen(url) as r, open(dest, "wb") as f:
37
+ total = int(r.headers.get("Content-Length") or 0)
38
+ done, shown = 0, -5
39
+ while True:
40
+ block = r.read(1 << 20)
41
+ if not block:
42
+ break
43
+ f.write(block)
44
+ done += len(block)
45
+ if total and 100 * done // total >= shown + 5:
46
+ shown = 100 * done // total
47
+ log(f" {shown:3d} % ({done / 1e6:.0f} / {total / 1e6:.0f} MB)")
48
+
49
+
50
+ def default_model(log=print):
51
+ """Folder of the released model; downloaded, checked and unzipped the first time."""
52
+ root = cache_dir()
53
+ folder = os.path.join(root, MODEL_NAME)
54
+ if os.path.exists(os.path.join(folder, "model.safetensors")):
55
+ return folder
56
+ os.makedirs(root, exist_ok=True)
57
+ url = os.environ.get("JOINTRECALIGN_MODEL_URL", MODEL_URL)
58
+ part = os.path.join(root, MODEL_NAME + ".zip.part")
59
+ log(f"First use: downloading the model (1.2 GB, only once) into {root}")
60
+ try:
61
+ _fetch(url, part, log)
62
+ except Exception as e:
63
+ raise RuntimeError(f"could not download the model from {url} ({e}).\n"
64
+ "Check the internet connection and try again, or download the zip by hand, "
65
+ "unzip it and give its folder with --model.") from None
66
+ h = hashlib.sha256()
67
+ with open(part, "rb") as f:
68
+ for block in iter(lambda: f.read(1 << 20), b""):
69
+ h.update(block)
70
+ if h.hexdigest() != MODEL_SHA256 and "JOINTRECALIGN_MODEL_URL" not in os.environ:
71
+ os.remove(part)
72
+ raise RuntimeError("the downloaded model is damaged (checksum mismatch); please try again.")
73
+ log("Unzipping the model")
74
+ tmp = folder + ".tmp"
75
+ shutil.rmtree(tmp, ignore_errors=True)
76
+ with zipfile.ZipFile(part) as z:
77
+ z.extractall(tmp)
78
+ shutil.rmtree(folder, ignore_errors=True)
79
+ os.replace(os.path.join(tmp, MODEL_NAME), folder)
80
+ shutil.rmtree(tmp, ignore_errors=True)
81
+ os.remove(part)
82
+ return folder
@@ -0,0 +1 @@
1
+ """Alignment evaluation: metrics, attention/DTW, latent-SIL Viterbi and CTC forced-alignment decoders."""
@@ -0,0 +1,214 @@
1
+ #!/usr/bin/env python3
2
+ """Score a checkpoint on an evaluation manifest: DTW on the cross-attention energy.
3
+
4
+ The model aligns its own CTC greedy decode (no reference phones, no prior),
5
+ chunk by chunk: no padding (the model sees exactly [start, end]), onset-frame
6
+ extraction with contiguous ends, no silence trimming. ctcfa_eval.py uses the
7
+ same conventions, so the two are directly comparable.
8
+
9
+ Manifest (JSON):
10
+ {"files": {
11
+ "<file_id>": {
12
+ "wav": "/path/to/audio.wav", # any rate, mono or not
13
+ "chunks": [{"start": 0.0, "end": 12.3}, ...], # seconds, scored segments
14
+ "ref_intervals": [{"phoneme": "a", "start": 0.12, "end": 0.20}, ...],
15
+ "style": "read", # optional grouping key
16
+ "pause_intervals": [[s, e], ...], # optional, SIL scoring
17
+ "excluded_intervals": [[s, e], ...] # optional, SIL scoring
18
+ }, ...}}
19
+
20
+ Frame timing is read from the model config (frame stride and receptive field),
21
+ so the same code scores 20 ms and 10 ms models.
22
+ """
23
+ import numpy as np, torch, soundfile as sf, soxr, unicodedata
24
+ from itertools import groupby
25
+
26
+ SR = 16000
27
+ FRAME_STRIDE_S = 0.02 # wav2vec2: 320 samples @ 16 kHz (default only)
28
+
29
+
30
+ def model_sr(model):
31
+ """Sampling rate the model was trained on (config.input_sampling_rate,
32
+ 16 kHz when absent)."""
33
+ return int(getattr(model.config, "input_sampling_rate", SR) or SR)
34
+
35
+
36
+ def frame_stride_s(model):
37
+ """Seconds per encoder frame: conv hop (samples) / input rate. 0.02 for the
38
+ standard model; 0.01 with last conv stride 1 or 32 kHz input.
39
+ Frame i starts at i * stride (checked: 10 ms frame 2i == 20 ms frame i)."""
40
+ return getattr(model.config, "inputs_to_logits_ratio", 320) / model_sr(model)
41
+
42
+
43
+ def frame_time_offset_s(model):
44
+ """Fixed, architecture-only correction from frame index to boundary time.
45
+
46
+ Frame i of the conv front-end sees samples [i*hop, i*hop + RF) (no padding),
47
+ so its centre is at i*hop + RF/2. A label change at frame i is placed halfway
48
+ between the centres of frames i-1 and i:
49
+ boundary = i*hop + (RF - hop) / 2
50
+ RF and hop come from config.conv_kernel / conv_stride. Nothing is tuned on data:
51
+ +2.5 ms for the 20 ms model, +7.5 ms for 10 ms (last stride 1), +1.25 ms at 32 kHz.
52
+ """
53
+ rf, hop = 1, 1
54
+ for k, s in zip(model.config.conv_kernel, model.config.conv_stride):
55
+ rf += (k - 1) * hop
56
+ hop *= s
57
+ return (rf - hop) / 2 / model_sr(model)
58
+
59
+
60
+ def resample(a, orig_sr, target_sr):
61
+ """As librosa.resample (default soxr_hq, length ceil(n * ratio)), without librosa."""
62
+ n = int(np.ceil(a.shape[-1] * float(target_sr) / orig_sr))
63
+ y = soxr.resample(a, orig_sr, target_sr, quality="soxr_hq")
64
+ y = y[:n] if len(y) >= n else np.pad(y, (0, n - len(y)))
65
+ return np.asarray(y, dtype=a.dtype)
66
+
67
+
68
+ def to_model_rate(seg, model):
69
+ """16 kHz chunk -> the model's input rate. Audio is always loaded at 16 kHz
70
+ first, so a 32 kHz model sees the same 8 kHz band limit as its (upsampled
71
+ 16 kHz) training data."""
72
+ sr = model_sr(model)
73
+ if sr == SR:
74
+ return seg
75
+ return resample(seg, SR, sr).astype(np.float32)
76
+
77
+
78
+ # Hypothesis tokens rewritten before scoring (affricates of the training phone
79
+ # set that the reference inventory writes as one fricative).
80
+ HYP_FIX = {"ts": "s", "tʃ": "ʃ"}
81
+ SKIP_TOK = ("", " ", "[", "|", "[PAD]", "[UNK]", "[SIL]")
82
+
83
+
84
+ def load_audio(path):
85
+ a, sr = sf.read(path)
86
+ if a.ndim > 1:
87
+ a = np.mean(a, axis=1)
88
+ if sr != SR:
89
+ a = resample(a, sr, SR)
90
+ return a.astype(np.float32)
91
+
92
+
93
+ def dtw_path(att):
94
+ """[T, P] -> [T] phone index per frame.
95
+
96
+ Steps [1,1] and [1,0]: no skips, every frame consumed once, every phone
97
+ gets at least one frame.
98
+ """
99
+ from librosa.sequence import dtw # evaluation only (pip install ".[eval]")
100
+ T, P = att.shape
101
+ if P == 0 or T < P:
102
+ return None
103
+ if P == 1:
104
+ return np.zeros(T, dtype=np.int32)
105
+ _, wp = dtw(C=-att, step_sizes_sigma=np.array([[1, 1], [1, 0]]))
106
+ path = np.full(T, -1, dtype=np.int32)
107
+ for fr, ph in wp: # librosa returns the path reversed
108
+ if 0 <= fr < T and path[fr] < ph:
109
+ path[fr] = ph
110
+ last = 0
111
+ for t in range(T):
112
+ if path[t] < 0:
113
+ path[t] = last
114
+ else:
115
+ last = path[t]
116
+ return path
117
+
118
+
119
+ @torch.inference_mode()
120
+ def run_manifest(model, feature_extractor, tokenizer, manifest,
121
+ device="cuda", max_files=None):
122
+ """-> {file_id: {ref_intervals, hyp_intervals, ref_seq, hyp_seq, style}}"""
123
+ was_training = model.training
124
+ model.eval()
125
+ store = {}
126
+ stride = frame_stride_s(model)
127
+ offset = frame_time_offset_s(model) # receptive-field centre rule
128
+ try:
129
+ for nm, meta in list(manifest["files"].items())[:max_files]:
130
+ wav = torch.from_numpy(load_audio(meta["wav"])).float()
131
+ hyp = []
132
+ for ch in meta["chunks"]:
133
+ s0, s1 = int(ch["start"] * SR), int(ch["end"] * SR)
134
+ seg = wav[s0:s1]
135
+ if seg.numel() < 400: # under one conv receptive field
136
+ continue
137
+ # the extractor only normalises; pass its own rate so a 16 kHz
138
+ # extractor also serves a 32 kHz model
139
+ x = feature_extractor(to_model_rate(seg.numpy(), model),
140
+ sampling_rate=feature_extractor.sampling_rate,
141
+ return_tensors="pt", return_attention_mask=True)
142
+ o = model(input_values=x.input_values.to(device),
143
+ attention_mask=x.attention_mask.to(device),
144
+ labels=None, # target = CTC greedy decode
145
+ prior_weight=0.0, # no beta-binomial prior
146
+ ctc_weight=0.0, align_weight=0.0, contrastive_weight=0.0)
147
+ T, P = int(o.input_lengths[0]), int(o.text_lengths[0])
148
+ if P == 0:
149
+ continue
150
+ # log_softmax then exp: softmax-then-log clips the losers to a
151
+ # constant floor and destroys their ordering.
152
+ att = np.exp(o.energy[0, :T, :P].float().log_softmax(-1).cpu().numpy())
153
+ path = dtw_path(att)
154
+ if path is None:
155
+ continue
156
+ tokens = tokenizer.convert_ids_to_tokens(
157
+ o.predicted_phone_ids[0, :P].cpu().tolist())
158
+
159
+ # run-length encode; keep each phone's ONSET frame
160
+ spans, n = [], 0
161
+ for idx, grp in groupby(path.tolist()):
162
+ spans.append((idx, n))
163
+ n += sum(1 for _ in grp)
164
+
165
+ ca = []
166
+ for pi, sframe in spans:
167
+ ph = tokens[pi]
168
+ if ph in SKIP_TOK:
169
+ continue
170
+ if len(ph) == 1 and unicodedata.combining(ph):
171
+ if ca:
172
+ ca[-1]["phoneme"] += ph
173
+ continue
174
+ ca.append({"phoneme": HYP_FIX.get(ph, ph),
175
+ "start": s0 / SR + sframe * stride + offset,
176
+ "end": None})
177
+ # contiguous ends: end_i = start_{i+1}; last = chunk end
178
+ for i in range(len(ca) - 1):
179
+ ca[i]["end"] = ca[i + 1]["start"]
180
+ if ca:
181
+ ca[-1]["end"] = s0 / SR + n * stride + offset
182
+ hyp.extend(ca)
183
+
184
+ hyp.sort(key=lambda x: x["start"])
185
+ store[nm] = {"ref_intervals": meta["ref_intervals"],
186
+ "hyp_intervals": hyp,
187
+ "ref_seq": [x["phoneme"] for x in meta["ref_intervals"]],
188
+ "hyp_seq": [x["phoneme"] for x in hyp],
189
+ "style": meta.get("style", "ALL")}
190
+ finally:
191
+ model.train(was_training)
192
+ return store
193
+
194
+
195
+ def duration_fidelity(store, align_sequences, gate=0.5):
196
+ """Slope of hypothesis duration on reference duration, over matched phones.
197
+ 1.0 = faithful; 0.0 = every phone gets the same length whatever it is.
198
+ The gate discards pairs the edit-distance matched across seconds of audio.
199
+ """
200
+ R, H = [], []
201
+ for d in store.values():
202
+ for i, j in align_sequences(d["ref_seq"], d["hyp_seq"]):
203
+ if i is None or j is None or d["ref_seq"][i] != d["hyp_seq"][j]:
204
+ continue
205
+ r, h = d["ref_intervals"][i], d["hyp_intervals"][j]
206
+ if abs(h["start"] - r["start"]) > gate or abs(h["end"] - r["end"]) > gate:
207
+ continue
208
+ R.append((r["end"] - r["start"]) * 1000)
209
+ H.append((h["end"] - h["start"]) * 1000)
210
+ if len(R) < 100:
211
+ return {}
212
+ R, H = np.array(R), np.array(H)
213
+ return {"dur_slope": float(np.polyfit(R, H, 1)[0]),
214
+ "dur_corr": float(np.corrcoef(R, H)[0, 1])}
@@ -0,0 +1,131 @@
1
+ """Score a checkpoint with CTC forced alignment on its CTC head (no attention).
2
+
3
+ Same manifest, chunks and output format as attention_eval.run_manifest, so the
4
+ two are directly comparable; only the boundary extraction differs:
5
+
6
+ attention (run_manifest): DTW on the cross-attention energy, onset frames,
7
+ contiguous ends.
8
+ ctcfa (this file): greedy CTC decode, torchaudio forced_align of that
9
+ sequence on the CTC log posteriors, one interval per
10
+ non-blank run with its own start AND end frame
11
+ (blank frames between phones stay unassigned).
12
+
13
+ The decoded phone sequence is the same CTC greedy decode the attention path
14
+ aligns, so PER matches the attention eval; only the boundaries change.
15
+
16
+ end_mode="contiguous" instead ends each phone where the next one starts and
17
+ the last phone of a chunk at the chunk end, exactly as run_manifest does for
18
+ the DTW path, so blank frames go to the preceding phone.
19
+
20
+ Frame timing follows attention_eval: frame stride and receptive-field offset
21
+ are read from the model config, so frame f of a chunk starting at cs is at
22
+ cs + f * stride + offset, for 20 ms and 10 ms models alike.
23
+ """
24
+ import unicodedata
25
+
26
+ import numpy as np
27
+ import torch
28
+ import torchaudio
29
+
30
+ from .attention_eval import (SR, HYP_FIX, SKIP_TOK, load_audio, frame_stride_s,
31
+ frame_time_offset_s, to_model_rate)
32
+
33
+
34
+ def ctc_logits(o):
35
+ for name in ("logits", "ctc_logits"):
36
+ v = getattr(o, name, None)
37
+ if v is not None:
38
+ return v
39
+ raise AttributeError("no CTC logits in the model output")
40
+
41
+
42
+ @torch.inference_mode()
43
+ def run_manifest_ctcfa(model, feature_extractor, tokenizer, manifest,
44
+ device="cuda", max_files=None, end_mode="span"):
45
+ """-> {file_id: {ref_intervals, hyp_intervals, ref_seq, hyp_seq, style}}"""
46
+ was_training = model.training
47
+ model.eval()
48
+ blank = tokenizer.pad_token_id
49
+ stride = frame_stride_s(model)
50
+ offset = frame_time_offset_s(model)
51
+ store, n_fail = {}, 0
52
+ try:
53
+ for nm, meta in list(manifest["files"].items())[:max_files]:
54
+ wav = torch.from_numpy(load_audio(meta["wav"])).float()
55
+ hyp = []
56
+ for ch in meta["chunks"]:
57
+ s0, s1 = int(ch["start"] * SR), int(ch["end"] * SR)
58
+ seg = wav[s0:s1]
59
+ if seg.numel() < 400: # under one conv receptive field
60
+ continue
61
+ x = feature_extractor(to_model_rate(seg.numpy(), model),
62
+ sampling_rate=feature_extractor.sampling_rate,
63
+ return_tensors="pt", return_attention_mask=True)
64
+ o = model(input_values=x.input_values.to(device),
65
+ attention_mask=x.attention_mask.to(device),
66
+ labels=None, prior_weight=0.0,
67
+ ctc_weight=0.0, align_weight=0.0, contrastive_weight=0.0)
68
+ T = int(o.input_lengths[0])
69
+ logp = ctc_logits(o)[0, :T].float().log_softmax(-1).cpu()
70
+
71
+ # greedy collapse -> forced-alignment target
72
+ ids, prev = [], None
73
+ for t in logp.argmax(-1).tolist():
74
+ if t != prev and t != blank:
75
+ ids.append(t)
76
+ prev = t
77
+ if not ids:
78
+ continue
79
+ try:
80
+ aligned, _ = torchaudio.functional.forced_align(
81
+ logp.unsqueeze(0).contiguous(), torch.tensor([ids], dtype=torch.int32),
82
+ torch.tensor([T], dtype=torch.int32), torch.tensor([len(ids)], dtype=torch.int32),
83
+ blank=blank)
84
+ except Exception as e: # e.g. more targets than frames
85
+ n_fail += 1
86
+ print(f" [forced_align skipped] {nm} {ch['start']:.2f}-{ch['end']:.2f}: {e}")
87
+ continue
88
+ aligned = aligned[0].tolist()
89
+
90
+ # runs with start AND end frame; blank frames stay unassigned
91
+ spans, run_id, run_start = [], None, 0
92
+ for fi, tid in enumerate(aligned):
93
+ if tid != run_id:
94
+ if run_id is not None and run_id != blank:
95
+ spans.append((run_id, run_start, fi))
96
+ run_id, run_start = tid, fi
97
+ if run_id is not None and run_id != blank:
98
+ spans.append((run_id, run_start, len(aligned)))
99
+
100
+ off = s0 / SR + offset
101
+ ca = []
102
+ for tid, sf, ef in spans:
103
+ ph = tokenizer.convert_ids_to_tokens(tid)
104
+ if ph in SKIP_TOK:
105
+ continue
106
+ if len(ph) == 1 and unicodedata.combining(ph):
107
+ if ca:
108
+ ca[-1]["phoneme"] += ph
109
+ ca[-1]["end"] = off + ef * stride
110
+ continue
111
+ ca.append({"phoneme": HYP_FIX.get(ph, ph),
112
+ "start": off + sf * stride,
113
+ "end": off + ef * stride})
114
+ if end_mode == "contiguous":
115
+ for i in range(len(ca) - 1):
116
+ ca[i]["end"] = ca[i + 1]["start"]
117
+ if ca:
118
+ ca[-1]["end"] = off + T * stride
119
+ hyp.extend(ca)
120
+
121
+ hyp.sort(key=lambda x: x["start"])
122
+ store[nm] = {"ref_intervals": meta["ref_intervals"],
123
+ "hyp_intervals": hyp,
124
+ "ref_seq": [x["phoneme"] for x in meta["ref_intervals"]],
125
+ "hyp_seq": [x["phoneme"] for x in hyp],
126
+ "style": meta.get("style", "ALL")}
127
+ finally:
128
+ model.train(was_training)
129
+ if n_fail:
130
+ print(f" [ctcfa] {n_fail} chunks skipped by forced_align")
131
+ return store