Download code/da/p2common.py from Modularcomputing/dynamic-accent-preview: direct link, hf CLI and curl.
- Browser
- Download file 12.8 kB
-
https://huggingface.co/Modularcomputing/dynamic-accent-preview/resolve/85bc6033c5db6b00b7aac86534c1ca73ceb44fef/code/da/p2common.py
- Command line
-
hf download hf://Modularcomputing/dynamic-accent-preview@85bc6033c5db6b00b7aac86534c1ca73ceb44fef/code/da/p2common.py
-
curl -L -o p2common.py https://huggingface.co/Modularcomputing/dynamic-accent-preview/resolve/85bc6033c5db6b00b7aac86534c1ca73ceb44fef/code/da/p2common.py
12.8 kB
| """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)))) | |