File size: 15,479 Bytes
c869096 8d3d08e c869096 8d3d08e c869096 8d3d08e c869096 0af696f c869096 0af696f 35394c4 0af696f c869096 1d1f2d7 8d3d08e 94873fd 8d3d08e 94873fd 8d3d08e 94873fd 8d3d08e 35394c4 8d3d08e 35394c4 c869096 8d3d08e c869096 0af696f 8d3d08e c869096 8d3d08e c869096 0af696f c869096 0af696f c869096 | 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 | """CTC forced alignment and goodness-of-pronunciation (GOP), in plain numpy.
Inputs are frame-level log-posteriors `logp` of shape (T, V) from a CTC phoneme model.
Nothing here depends on torch, so it is unit-testable with synthetic emissions.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass, field
import numpy as np
log = logging.getLogger("ihki.align")
NEG = -1e30
POOLS = ("max", "mean", "topk", "lse", "energy", "gated")
DEFAULT_POOL = "gated"
def env_pool() -> str:
"""The ratio pool GOP_RATIO_POOL names (read when a GopConfig is made, so after main.py loads .env).
Empty or unset: DEFAULT_POOL. An unknown name is logged and DEFAULT_POOL is used."""
pool = os.environ.get("GOP_RATIO_POOL", "").strip().lower() or DEFAULT_POOL
if pool not in POOLS:
log.warning("GOP_RATIO_POOL=%r is not one of %s: using %s", pool, ", ".join(POOLS), DEFAULT_POOL)
return DEFAULT_POOL
return pool
NARROW_PAD = 1
def env_vowel_window() -> int | None:
"""GOP_VOWEL_WINDOW: empty, unset or "wide" (None: a vowel reads the consonants' wide window), "narrow" (its
aligned span +- NARROW_PAD frames), or that pad as a frame count. Anything else is logged and "wide" used.
Wide is the default: with a pooled ratio it absorbs a transition spike without the narrow window's blind spot
(docs/gop-pooling.md, scripts/gop_window_bench.py)."""
raw = os.environ.get("GOP_VOWEL_WINDOW", "").strip().lower()
if raw in ("", "wide"):
return None
if raw == "narrow":
return NARROW_PAD
if raw.isdigit():
return int(raw)
log.warning("GOP_VOWEL_WINDOW=%r is not wide, narrow or a frame count: using wide", raw)
return None
@dataclass
class Alignment:
total_logp: float # log-prob of the best CTC path
spans: list[tuple[int, int]] # [start, end) frames where each target token is emitted
def ctc_viterbi(logp: np.ndarray, tokens: list[int], blank: int) -> Alignment | None:
"""Best CTC path that emits exactly `tokens`. Returns None if the audio is too short."""
T, _ = logp.shape
N = len(tokens)
if N == 0 or T < N:
return None
S = 2 * N + 1
ext = np.array([blank if s % 2 == 0 else tokens[s // 2] for s in range(S)])
# skip transition s-2 -> s allowed for non-blank states whose label differs from s-2
can_skip = np.zeros(S, dtype=bool)
can_skip[2:] = (ext[2:] != blank) & (ext[2:] != ext[:-2])
dp = np.full((T, S), NEG)
bp = np.zeros((T, S), dtype=np.int8) # 0 = stay, 1 = from s-1, 2 = from s-2
dp[0, 0] = logp[0, blank]
dp[0, 1] = logp[0, ext[1]]
for t in range(1, T):
prev = dp[t - 1]
stay = prev
one = np.concatenate(([NEG], prev[:-1]))
two = np.where(can_skip, np.concatenate(([NEG, NEG], prev[:-2])), NEG)
stacked = np.stack([stay, one, two])
choice = stacked.argmax(0)
dp[t] = stacked[choice, np.arange(S)] + logp[t, ext]
bp[t] = choice
# path must end in the last token or the trailing blank
end = S - 1 if dp[T - 1, S - 1] >= dp[T - 1, S - 2] else S - 2
total = float(dp[T - 1, end])
if total <= NEG / 2:
return None
states = np.empty(T, dtype=np.int64)
s = end
for t in range(T - 1, -1, -1):
states[t] = s
s -= int(bp[t, s])
spans: list[list[int]] = [[-1, -1] for _ in range(N)]
for t, st in enumerate(states):
if st % 2 == 1:
i = st // 2
if spans[i][0] < 0:
spans[i][0] = t
spans[i][1] = t + 1
return Alignment(total, [tuple(x) for x in spans])
@dataclass
class TokenScore:
score: float # 0..100
gop: float # log ratio target vs strongest competitor, <= 0
peak: float # max log-posterior of the target inside its window
competitor: int | None # strongest non-target, non-blank token in the window (None: nothing, or a sound we do not name)
deleted: bool # target barely present and nothing else either
realised: int | None = None # a class target (several correct realisations): the one said
other: bool = False # the strongest competitor is a sound outside the named set
comp_peak: float = NEG # max log-posterior of the strongest competitor (named or not) in the window
comp_frames: int = 0 # frames where that competitor clears deleted_comp (duration evidence)
window: tuple[int, int] = (0, 0) # [start, end) frames the target and its competitors were read from
@dataclass
class GopConfig:
deleted_peak: float = float(np.log(0.05)) # target this weak ...
deleted_comp: float = float(np.log(0.20)) # ... and no strong competitor -> deletion
confident_peak: float = 0.30 # target posterior at which the peak no longer lowers the score
# Softens the target/competitor ratio: at 0.5 a competitor twice as likely still scores 71, not 50.
# The zero-shot model is noisy on accented speech, so a linear ratio punishes its own uncertainty.
leniency: float = 0.5
# How the target/competitor ratio pools the window's frames (docs/gop-pooling.md). The peak behind
# `confidence` and `deleted` stays the max, so a peaky CTC emission is never diluted by its blanks.
ratio_pool: str = field(default_factory=env_pool) # max | mean | topk | lse | energy | gated (GOP_RATIO_POOL)
pool_k: int = 2 # topk: frames averaged per column
pool_tau: float = 0.5 # lse: temperature (0 -> max, 1 -> mean)
energy_gamma: float = 1.0 # energy: frame weight = (RMS / loudest in the window) ** gamma
energy_gate: float = 0.3 # gated: frames quieter than this fraction of the window's loudest are dropped
energy_spread: int = 1 # frames the energy is max-filtered over (CTC peaks lead/lag the acoustics)
pool_vowels_only: bool = True # consonants keep the max (bursts and voiceless sounds are short and quiet)
# Consonants need some surrounding blank for a CTC peak that leads/lags the forced span, but must not inherit an
# arbitrarily long pause up to the next token (e.g. a 760 ms window across a sentence break).
consonant_window: int | None = 4
# A vowel's competitors are read from its aligned span +- this many frames (None: the wide window consonants
# use). Its presence (`peak`, `deleted`) is still read from the wide window. GOP_VOWEL_WINDOW sets it.
vowel_window: int | None = field(default_factory=env_vowel_window)
def pool_frames(seg: np.ndarray, how: str = "max", k: int = 2, tau: float = 0.5,
weight: np.ndarray | None = None) -> np.ndarray:
"""Pool a window's (n, V) log-posteriors over time into one log-score per column.
max: the single best frame. topk: log of the mean of each column's k best frames. lse: the power mean
tau * log(mean(p ** (1/tau))), between the max (tau -> 0) and the mean (tau = 1). mean / energy / gated:
log of the weighted mean posterior, `weight` (uniform when None) normalised over the window.
"""
n = len(seg)
if how == "max" or n == 1:
return seg.max(0)
if how == "topk":
k = max(1, min(k, n))
return np.logaddexp.reduce(np.partition(seg, n - k, axis=0)[n - k:], axis=0) - np.log(k)
if how == "lse":
return tau * (np.logaddexp.reduce(seg / tau, axis=0) - np.log(n))
w = np.ones(n) if weight is None else np.asarray(weight, dtype=np.float64)
w = w / w.sum() if w.sum() > 0 else np.full(n, 1.0 / n)
return np.logaddexp.reduce(seg + np.log(np.maximum(w, 1e-300))[:, None], axis=0)
def energy_weights(energy: np.ndarray, how: str, gamma: float = 1.0, gate: float = 0.3) -> np.ndarray:
"""Frame weights of one window from its (already smoothed) RMS: soft (`energy`) or a 0/1 voice gate (`gated`)."""
e = energy / max(float(energy.max()), 1e-12)
return (e >= gate).astype(np.float64) if how == "gated" else e ** gamma
def spread_energy(energy: np.ndarray, spread: int) -> np.ndarray:
"""Max-filter over +-spread frames: a CTC emission a frame off the loud nucleus still gets its weight."""
out = energy.astype(np.float64).copy()
for s in range(1, spread + 1):
out[s:] = np.maximum(out[s:], energy[:-s])
out[:-s] = np.maximum(out[:-s], energy[s:])
return out
def token_window(spans: list[tuple[int, int]], i: int, T: int, pad: int | None = None) -> tuple[int, int]:
"""[start, end) frames token i is read from. Wide (pad None): from the end of token i-1 to the start of token i+1,
the blanks around the emission where a substituted consonant peaks. Narrow: its own span +- `pad` frames, inside
the wide window (a vowel is its syllable's loud nucleus, not a sound hiding in the blanks)."""
a = spans[i - 1][1] if i > 0 else 0
b = spans[i + 1][0] if i + 1 < len(spans) else T
a, b = min(a, spans[i][0]), max(b, spans[i][1])
if pad is None:
return a, b
return max(a, spans[i][0] - pad), min(b, spans[i][1] + pad)
def compute_token_gop(logp: np.ndarray, spans: list[tuple[int, int]], i: int, tokens: list[int], blank: int,
cfg: GopConfig, vowel: bool = False, accept: list[set[int] | None] | None = None,
n_real: int | None = None, named: set[int] | None = None, energy: np.ndarray | None = None,
edge: tuple[set[int], set[int]] | None = None, pooled: bool | None = None) -> TokenScore:
"""GOP of token i (see score_tokens), its window and pooling chosen by its class.
Presence is read from the wide window for every token: `peak` (behind the confidence) and `deleted` (target under
deleted_peak and no competitor over deleted_comp), so a vowel aligned to a single frame is still found deleted.
The competitor ratio of a vowel is read from its narrow window (cfg.vowel_window) and pooled with cfg.ratio_pool;
a consonant is capped at its span +- cfg.consonant_window and keeps the max unless cfg.pool_vowels_only is off.
`pooled` overrides the latter.
`edge`: ids that are no competitor on the frames between the token's span and its left / right neighbour (a vowel
gliding into j or w passes through i or u: coarticulation, not a substitution). `energy` is already spread.
"""
T = logp.shape[0]
accept = accept or [None] * len(tokens)
a, b = token_window(spans, i, T, cfg.consonant_window) if not vowel else token_window(spans, i, T)
ra, rb = token_window(spans, i, T, cfg.vowel_window) if vowel and cfg.vowel_window is not None else (a, b)
tok, (s0, s1) = tokens[i], spans[i]
seg = logp[a:b]
peak = float(seg[:, tok].max())
members = accept[i] or set()
realised = max(members, key=lambda m: seg[:, m].max()) if members else None
cols = (seg[:, :n_real] if n_real else seg).copy()
if edge:
for ids, lo, hi in ((edge[0], a, s0), (edge[1], s1, b)):
ids = [x for x in ids if x < cols.shape[1]]
if ids and hi > lo:
cols[lo - a:hi - a, ids] = NEG
mask = np.zeros(cols.shape[1], dtype=bool)
mask[blank] = True
for j in (i - 1, i, i + 1):
if 0 <= j < len(tokens):
for x in {tokens[j], *(accept[j] or ())}:
if x < len(mask):
mask[x] = True
deleted = bool(peak < cfg.deleted_peak and float(np.where(mask, NEG, cols.max(0)).max()) < cfg.deleted_comp)
how = cfg.ratio_pool if (pooled if pooled is not None else vowel or not cfg.pool_vowels_only) else "max"
weight = None
if how in ("energy", "gated"):
if energy is not None:
weight = energy_weights(energy[ra:rb], how, cfg.energy_gamma, cfg.energy_gate)
weight[max(0, s0 - ra):max(0, s1 - ra)] = 1.0 # the frames the token was emitted on count in full
how = "mean"
rcols = cols[ra - a:rb - a]
maxed = rcols.max(0)
ratio = maxed.copy() if how == "max" else pool_frames(rcols, how, cfg.pool_k, cfg.pool_tau, weight)
ratio[mask] = NEG
comp = int(ratio.argmax())
comp_peak = float(maxed[comp]) if ratio[comp] > NEG / 2 else NEG
# A lone CTC spike can be a breath or microphone onset rather than a produced consonant. Keep duration
# evidence separately from the peak so pronunciation policy can distinguish transients from substitutions.
comp_frames = int(np.count_nonzero(rcols[:, comp] >= cfg.deleted_comp)) if ratio[comp] > NEG / 2 else 0
tseg = logp[ra:rb, [tok]]
target = float(tseg.max()) if how == "max" else float(pool_frames(tseg, how, cfg.pool_k, cfg.pool_tau, weight)[0])
gop = target - max(target, float(ratio[comp]))
confidence = min(1.0, float(np.exp(peak)) / cfg.confident_peak)
score = 100.0 * float(np.exp(gop * cfg.leniency)) * float(np.sqrt(confidence))
present = comp_peak > NEG / 2
other = bool(present and named is not None and comp not in named)
return TokenScore(round(score, 1), round(gop, 3), round(peak, 3), comp if present and not other else None,
deleted, realised, other, round(comp_peak, 3), comp_frames, (int(ra), int(rb)))
def score_tokens(logp: np.ndarray, align: Alignment, tokens: list[int], blank: int,
cfg: GopConfig | None = None, accept: list[set[int] | None] | None = None,
n_real: int | None = None, named: set[int] | None = None,
energy: np.ndarray | None = None, pooled: list[bool] | None = None,
vowels: list[bool] | None = None,
edges: list[tuple[set[int], set[int]] | None] | None = None) -> list[TokenScore]:
"""Per-token GOP (compute_token_gop for each token).
The neighbours' own labels are excluded from the competitor set (coarticulation is not an error).
A class token (a virtual column pooling its correct realisations, `accept[i]` their ids) is scored on the
pooled column; its members are not competitors. Competitors come from the first `n_real` columns only, and
one outside `named` is reported as `other` rather than by name.
`vowels` marks the tokens that are vowels (narrow window, pooled ratio). Without it every token is read from the
wide window and pooled, unless `pooled` marks which are. `energy` is the per-frame RMS the energy pools need;
without it they fall back to the plain mean. `edges[i]`: see compute_token_gop.
"""
cfg = cfg or GopConfig()
if energy is not None:
energy = spread_energy(np.asarray(energy)[:logp.shape[0]], cfg.energy_spread)
return [compute_token_gop(logp, align.spans, i, tokens, blank, cfg, bool(vowels and vowels[i]), accept, n_real,
named, energy, edges[i] if edges else None,
(pooled[i] if pooled is not None else None if vowels is not None else True))
for i in range(len(tokens))]
def greedy_decode(logp: np.ndarray, blank: int) -> list[int]:
ids = logp.argmax(1)
out, prev = [], None
for x in ids:
x = int(x)
if x != prev and x != blank:
out.append(x)
prev = x
return out
def edit_distance(a: list, b: list, same=None) -> int:
same = same or (lambda x, y: x == y)
d = list(range(len(b) + 1))
for i, x in enumerate(a, 1):
prev, d[0] = d[0], i
for j, y in enumerate(b, 1):
prev, d[j] = d[j], min(d[j] + 1, d[j - 1] + 1, prev + (not same(x, y)))
return d[len(b)]
|