File size: 12,815 Bytes
256a3b6 | 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 | """Phase 2 shared tools: romanisation for MMS forced alignment, chunked aligner, pause normalisation,
pause / F0 / Yoruba-tone metrics. Used by prep2.py (data) and eval2.py (metrics)."""
import re, unicodedata
import numpy as np
# ------------------------------------------------------------------ text
_MAP = {"ọ": "o", "ẹ": "e", "ṣ": "s", "ị": "i", "ụ": "u", "ṅ": "n", "ń": "n", "ǹ": "n", "ḿ": "m", "ɓ": "b", "ɗ": "d",
"ƙ": "k", "ƴ": "y", "ŋ": "n", "ɛ": "e", "ɔ": "o", "ß": "s", "æ": "ae", "œ": "oe", "ø": "o", "đ": "d", "ł": "l"}
TONE_MARK = {"\u0301": 2, "\u0300": 0, "\u0304": 1} # acute=H, grave=L, macron=M
SENT_END = set(".?!")
CLAUSE = set(";:")
COMMA = set(",")
_VOW = set("aeiou")
def roman_char(c):
"""one NFC char -> (romanised latin string, tone or None)."""
lc = c.lower()
if lc in _MAP:
return _MAP[lc], None
d = unicodedata.normalize("NFD", lc)
base = "".join(x for x in d if unicodedata.category(x) != "Mn")
tone = None
for x in d:
if x in TONE_MARK:
tone = TONE_MARK[x]
base = "".join(_MAP.get(b, b) for b in base)
base = "".join(b for b in base if ("a" <= b <= "z") or b == "'")
return base, tone
def words_for_align(text):
"""text -> list of dict(word, rom, punct_after, chars=[(rom_char, orig_index, tone)]).
Words that romanise to nothing (digits, symbols) are aligned with the '*' star token."""
out = []
for m in re.finditer(r"\S+", text):
w = m.group(0)
chars = []
for k, c in enumerate(w):
r, t = roman_char(c)
for j, rc in enumerate(r):
chars.append((rc, m.start() + k, t if j == len(r) - 1 else None))
trail = re.sub(r"[\w'’]+", "", w[-3:]) if w else ""
p = None
if any(c in SENT_END for c in trail):
p = "sent"
elif any(c in CLAUSE for c in trail):
p = "clause"
elif any(c in COMMA for c in trail):
p = "comma"
rom = "".join(c[0] for c in chars)
out.append(dict(word=w, rom=rom if rom else "*", punct=p, chars=chars))
return out
# ------------------------------------------------------------------ aligner
class Aligner:
"""torchaudio MMS_FA (CC-BY-NC weights, used only as a data tool/judge) with chunked emissions for long audio."""
def __init__(self, device="cuda"):
import torch, torchaudio
self.torch = torch
self.F = torchaudio.functional
b = torchaudio.pipelines.MMS_FA
self.model = b.get_model().to(device).eval().half()
self.dict = b.get_dict()
self.dev = device
self.rate = 320 # samples per emission frame at 16 kHz (20 ms)
def emission(self, y16, chunk_s=30, ctx_s=2):
torch = self.torch
sr = 16000
x = torch.from_numpy(y16).float().to(self.dev)
if len(y16) <= (chunk_s + 2 * ctx_s) * sr:
with torch.inference_mode():
e, _ = self.model(x[None].half())
return e[0].float().log_softmax(-1)
outs = []
step, ctx = chunk_s * sr, ctx_s * sr
nfr_total = len(y16) // self.rate
for s in range(0, len(y16), step):
a, b = max(0, s - ctx), min(len(y16), s + step + ctx)
with torch.inference_mode():
e, _ = self.model(x[a:b][None].half())
e = e[0].float().log_softmax(-1)
f0 = (s - a) // self.rate
nfr = min(step, len(y16) - s) // self.rate
outs.append(e[f0:f0 + nfr])
e = torch.cat(outs, 0)
return e[:max(nfr_total, 1)]
def align(self, y16, words):
"""-> per word (start_s, end_s, score) and per char spans [(start_s, end_s)], or None on failure."""
torch = self.torch
em = self.emission(y16)
toks, wid = [], []
for i, w in enumerate(words):
for c in w["rom"]:
if c in self.dict:
toks.append(self.dict[c]); wid.append(i)
if not toks or len(toks) * 1 > em.shape[0]:
return None
t = torch.tensor([toks], dtype=torch.int32, device=self.dev)
try:
ali, sc = self.F.forced_align(em[None], t, blank=0)
except Exception:
return None
ali, sc = ali[0].cpu().numpy(), sc[0].exp().cpu().numpy()
# token spans (merge repeats; blank=0)
spans, i, k = [], 0, 0
T = len(ali)
while i < T:
if ali[i] == 0:
i += 1; continue
j = i
while j + 1 < T and ali[j + 1] == ali[i] and not (j + 1 < T and False):
j += 1
spans.append((i, j + 1, float(sc[i:j + 1].mean())))
i = j + 1
# repeated identical consecutive tokens (e.g. "aa") collapse in the path above only if no blank between;
# forced_align always inserts a blank between identical tokens, so len(spans)==len(toks)
if len(spans) != len(toks):
return None
r = self.rate / 16000
W = [[None, None, []] for _ in words]
chars = [[] for _ in words]
for (a, b, s), w in zip(spans, wid):
if W[w][0] is None:
W[w][0] = a * r
W[w][1] = b * r; W[w][2].append(s); chars[w].append((a * r, b * r))
res = []
for i, (a, b, s) in enumerate(W):
if a is None:
return None
res.append(dict(start=a, end=b, score=float(np.mean(s)), chars=chars[i]))
return res
# ------------------------------------------------------------------ energy / pauses
def frame_db(y, sr, hop_s=0.01):
hop = int(sr * hop_s)
n = max(1, len(y) // hop)
fr = y[: n * hop].reshape(n, hop)
e = 10 * np.log10((fr.astype(np.float64) ** 2).mean(1) + 1e-10)
return e
def silence_mask(y, sr, rel_db=-40.0, hop_s=0.01):
e = frame_db(y, sr, hop_s)
ref = np.percentile(e, 98)
return e < ref + rel_db
def runs(mask):
"""-> list of (start, end) index runs where mask is True."""
out, i, n = [], 0, len(mask)
while i < n:
if mask[i]:
j = i
while j < n and mask[j]:
j += 1
out.append((i, j)); i = j
else:
i += 1
return out
PAUSE_TARGET = { # (min, max) silence seconds after a word, by the punctuation that follows it
"sent": (0.30, 0.60),
"clause": (0.20, 0.50),
"comma": (0.00, 0.40),
None: (0.00, 0.20),
}
# profile B (Phase 2 v2): keeps natural phrase breaks where there is no punctuation (the real anchor pauses at
# ~14% of unpunctuated word boundaries), still removes hesitations; set P2PAUSE=B
import os as _os
if _os.environ.get("P2PAUSE") == "B":
PAUSE_TARGET = {"sent": (0.35, 0.70), "clause": (0.25, 0.55), "comma": (0.00, 0.50), None: (0.00, 0.35)}
LEAD_S, TAIL_S = 0.12, 0.18
def normalise_pauses(y, sr, words, ali, seg_a, seg_b, sil=None, lo_s=0.0, hi_s=None):
"""Rebuild one segment [word seg_a .. seg_b] of y: edge silence fixed to LEAD_S/TAIL_S, every inter-word
silence clamped to PAUSE_TARGET[punct] (only truly silent frames are removed; silence is inserted, as a
copy of the quietest local frames, when a sentence/clause boundary has too little). -> (y_out, pauses)"""
hop = int(0.01 * sr)
if sil is None:
sil = silence_mask(y, sr)
hi_s = len(y) / sr if hi_s is None else hi_s
xf = int(0.005 * sr)
pieces, pauses = [], []
a0 = ali[seg_a]["start"]
start = int(max(lo_s, a0 - LEAD_S, 0.0) * sr)
cur = start
for i in range(seg_a, seg_b):
e_i, s_n = ali[i]["end"], ali[i + 1]["start"]
f_a, f_b = int(e_i / 0.01), int(s_n / 0.01) + 1
# longest silent run inside the gap (with 20 ms guard to the word edges)
best = None
for ra, rb in runs(sil[f_a:f_b]):
if best is None or rb - ra > best[1] - best[0]:
best = (ra, rb)
lo, hi = PAUSE_TARGET[words[i]["punct"]]
if best is None:
cur_sil = 0.0; mid = int((e_i + s_n) / 2 * sr)
else:
ra, rb = (f_a + best[0]) * hop, (f_a + best[1]) * hop
cur_sil = (rb - ra) / sr; mid = (ra + rb) // 2
if cur_sil > hi and best is not None: # shrink: cut the middle of the silent run
cut = int((cur_sil - hi) * sr)
c0 = mid - cut // 2
pieces.append(y[cur:c0]); cur = c0 + cut
pauses.append(hi)
elif cur_sil < lo: # insert quiet frames at the quietest point of the gap
add = int((lo - cur_sil) * sr)
g0, g1 = int(e_i * sr), max(int(s_n * sr), int(e_i * sr) + hop)
seg = y[g0:g1]
if len(seg) >= hop:
k = int(np.argmin(frame_db(seg, sr))) * hop
ins_at = g0 + k
q = y[ins_at:ins_at + hop]
else:
ins_at, q = mid, np.zeros(hop, dtype=y.dtype)
filler = np.tile(q, add // hop + 1)[:add] * 0.5
pieces.append(y[cur:ins_at]); pieces.append(filler); cur = ins_at
pauses.append(lo)
else:
pauses.append(cur_sil)
end = min(len(y), int(min(hi_s, ali[seg_b]["end"] + TAIL_S) * sr))
pieces.append(y[cur:end])
out = np.concatenate([p for p in pieces if len(p)]) if pieces else y[start:end]
# pad edges with silence if the source had less than LEAD/TAIL available
lead_have = a0 - start / sr
if lead_have < LEAD_S - 0.02:
out = np.concatenate([np.zeros(int((LEAD_S - lead_have) * sr), out.dtype), out])
tail_have = end / sr - ali[seg_b]["end"]
if tail_have < TAIL_S - 0.02:
out = np.concatenate([out, np.zeros(int((TAIL_S - tail_have) * sr), out.dtype)])
# tiny fades at the edges
f = min(xf, len(out) // 4)
if f > 0:
out[:f] *= np.linspace(0, 1, f); out[-f:] *= np.linspace(1, 0, f)
return out.astype(np.float32), pauses
# ------------------------------------------------------------------ metrics on audio
def pause_stats(y, sr, min_pause=0.15):
"""VAD (energy, -40 dB vs 98th pct) pauses: leading/trailing silence and internal pauses >= min_pause."""
sil = silence_mask(y, sr)
R = runs(sil)
n = len(sil)
lead = R[0][1] * 0.01 if R and R[0][0] == 0 else 0.0
tail = (n - R[-1][0]) * 0.01 if R and R[-1][1] == n else 0.0
internal = [(b - a) * 0.01 for a, b in R if a > 0 and b < n and (b - a) * 0.01 >= min_pause]
speech = max(1e-3, n * 0.01 - lead - tail)
return dict(lead=round(lead, 3), tail=round(tail, 3), pauses=[round(p, 3) for p in internal],
n_pauses=len(internal), pause_rate=round(len(internal) / speech, 4),
pause_frac=round(sum(internal) / speech, 4), speech_s=round(speech, 3))
def f0_track(y16):
import pyworld as pw
x = y16.astype(np.float64)
f0, t = pw.dio(x, 16000, f0_floor=60, f0_ceil=500, frame_period=10)
return pw.stonemask(x, f0, t, 16000)
def f0_stats(y16):
f0 = f0_track(y16)
v = f0[f0 > 0]
if len(v) < 20:
return dict(voiced=float(len(v)), f0_med=None, st_std=None, st_range=None)
st = 12 * np.log2(v / np.median(v))
d = np.diff(st)
return dict(voiced_frac=round(len(v) / len(f0), 3), f0_med=round(float(np.median(v)), 1),
st_std=round(float(st.std()), 3), st_range=round(float(np.percentile(st, 95) - np.percentile(st, 5)), 3),
st_vel=round(float(np.abs(d[np.abs(d) < 3]).mean()), 4))
def tone_pairs(y16, words, ali):
"""Yoruba tone-direction accuracy: for consecutive tone-bearing vowels whose tones differ, is the sign of the
median-F0 change (semitones) the same as the sign of the tone change? -> (n_pairs, n_correct)"""
f0 = f0_track(y16)
vals = []
for w, a in zip(words, ali):
k = 0
for (rc, oi, tone), (s, e) in zip([c for c in w["chars"] if c[0] in "abcdefghijklmnopqrstuvwxyz'"], a["chars"]):
if rc in _VOW or (rc in "nm" and tone is not None):
t = 1 if tone is None else tone
fr = f0[int(s / 0.01): int(e / 0.01) + 1]
fr = fr[fr > 0]
vals.append((t, float(np.median(12 * np.log2(fr))) if len(fr) else None))
n = c = 0
for (t1, f1), (t2, f2) in zip(vals, vals[1:]):
if t1 == t2 or f1 is None or f2 is None:
continue
n += 1
c += int(np.sign(f2 - f1) == np.sign(t2 - t1))
return n, c
def wasserstein1(a, b):
"""1-D earth mover's distance between two samples."""
if not len(a) or not len(b):
return None
a, b = np.sort(a), np.sort(b)
q = np.linspace(0, 1, 101)
return float(np.mean(np.abs(np.quantile(a, q) - np.quantile(b, q))))
|