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.
- jointrecalign/__init__.py +1 -0
- jointrecalign/__main__.py +4 -0
- jointrecalign/download.py +82 -0
- jointrecalign/evaluation/__init__.py +1 -0
- jointrecalign/evaluation/attention_eval.py +214 -0
- jointrecalign/evaluation/ctcfa_eval.py +131 -0
- jointrecalign/evaluation/metrics.py +266 -0
- jointrecalign/evaluation/sil_eval.py +311 -0
- jointrecalign/evaluation/vad_chunking.py +166 -0
- jointrecalign/model.py +609 -0
- jointrecalign/modules.py +340 -0
- jointrecalign/pool_alignment.py +958 -0
- jointrecalign/praat.py +43 -0
- jointrecalign/praat_plugin/setup.praat +2 -0
- jointrecalign/praat_plugin/transcribe.praat +44 -0
- jointrecalign/reference.py +204 -0
- jointrecalign/report.py +193 -0
- jointrecalign/report_assets/index.html +102 -0
- jointrecalign/report_assets/page.html +507 -0
- jointrecalign/sil_fs.py +278 -0
- jointrecalign/transcribe.py +326 -0
- jointrecalign/transcribe_cli.py +434 -0
- jointrecalign-0.1.0.dist-info/METADATA +535 -0
- jointrecalign-0.1.0.dist-info/RECORD +28 -0
- jointrecalign-0.1.0.dist-info/WHEEL +5 -0
- jointrecalign-0.1.0.dist-info/entry_points.txt +3 -0
- jointrecalign-0.1.0.dist-info/licenses/LICENSE +21 -0
- jointrecalign-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Joint phone recognition and alignment with wav2vec2 (CTC + cross-attention aligner)."""
|
|
@@ -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
|