File size: 11,476 Bytes
c0bce59 | 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 | """S5 — CTC forced alignment of a transcript to character CTC emissions (Viterbi).
Given log-softmax emissions ``[num_frames, vocab]`` from a character CTC model (e.g.
``mpoyraz_ctc_tr``) and the primary transcript, find the single most likely CTC path that
emits exactly the transcript's characters (words separated by the model's word-delimiter
token) and read word intervals off that path. Plain numpy, no torchaudio dependency
(``torchaudio.functional.forced_align`` is deprecated in the 2.8 line, R8).
Text handling: each transcript token is NFC + ``tr_lower``-ed, circumflexes are folded
(â/î/û → a/i/u: the model has no such letters) and characters outside the vocabulary that
are punctuation are dropped for alignment only. A token with no alignable character
(``…``, ``-``) is glued to the previous word (or the next one at the start), so the
returned words keep their original spelling and punctuation. Any other character
outside the vocabulary (digits, foreign letters) makes the segment unalignable.
"""
from __future__ import annotations
import itertools
import math
import unicodedata
from collections.abc import Mapping, Sequence
from dataclasses import dataclass, field
from typing import TYPE_CHECKING
from nida.data.types import TimedWord
from nida.text.turkish import nfc, tr_lower
if TYPE_CHECKING:
import numpy as np
from numpy.typing import NDArray
__all__ = ["AlignmentResult", "align_words", "alignment_units"]
_CIRCUMFLEX_FOLD = str.maketrans({"â": "a", "î": "i", "û": "u"})
# Typographic apostrophes (U+2019, U+02BC) are the same letter-joining apostrophe as U+0027.
_APOSTROPHES = str.maketrans({"\u2019": "'", "\u02bc": "'"})
#: Allowed difference between ``num_frames * hop`` and the segment duration, in frames
#: (wav2vec2 yields ``floor((samples - 400) / 320) + 1`` frames, i.e. up to ~1 frame short).
FRAME_SLACK = 2
@dataclass(frozen=True)
class AlignmentResult:
"""Either ``words`` (aligned) or ``failure`` (a machine-readable reason) is set."""
words: tuple[TimedWord, ...] | None
failure: str | None
details: Mapping[str, object] = field(default_factory=dict)
def __post_init__(self) -> None:
if (self.words is None) == (self.failure is None):
raise ValueError("AlignmentResult needs exactly one of words / failure")
if self.words is not None and self.details:
raise ValueError("an aligned result carries no failure details")
def alignment_units(
tokens: Sequence[str], vocab: Mapping[str, int]
) -> tuple[list[str], list[str]] | str:
"""Group transcript tokens into alignable words.
Returns:
``(display_words, alignment_strings)`` of equal length, or a failure string
``"unalignable_chars:<chars>"`` / ``"no_alignable_text"``. Apostrophes join
letters only: leading/trailing ones are not aligned, and a token made only of
punctuation (``…``, ``—``, ``'``) is glued to the previous word (or the next one at
the start) *without* the space between them (``a — b`` → ``a—``, ``b``), because a
returned word cannot contain whitespace.
"""
display: list[str] = []
targets: list[str] = []
pending_prefix = ""
bad: set[str] = set()
for raw in tokens:
token = nfc(raw)
if not token or any(ch.isspace() for ch in token):
raise ValueError(f"invalid transcript token {raw!r}")
chars: list[str] = []
for ch in tr_lower(token).translate(_CIRCUMFLEX_FOLD).translate(_APOSTROPHES):
if ch in vocab:
chars.append(ch)
elif unicodedata.category(ch).startswith("P"):
continue
else:
bad.add(ch)
chars = list("".join(chars).strip("'"))
if not chars:
if display:
display[-1] += token
else:
pending_prefix += token
continue
display.append(pending_prefix + token)
pending_prefix = ""
targets.append("".join(chars))
if bad:
return "unalignable_chars:" + "".join(sorted(bad))
if not targets:
return "no_alignable_text"
return display, targets
def _viterbi(log_probs: NDArray[np.float64], labels: Sequence[int], blank: int) -> list[int] | None:
"""Best CTC state path; ``None`` if the label sequence cannot fit in the frames.
States are the blank-extended sequence ``b l1 b l2 b … lN b`` (index ``2k+1`` is
label ``k``). Returns the state index for every frame.
"""
import numpy as np
num_frames = log_probs.shape[0]
ext = np.full(2 * len(labels) + 1, blank, dtype=np.int64)
ext[1::2] = labels
num_states = ext.shape[0]
# A skip s-2 -> s is allowed into a label that differs from the label two back.
can_skip = np.zeros(num_states, dtype=bool)
can_skip[3::2] = ext[3::2] != ext[1:-2:2]
neg_inf = -np.inf
score = np.full(num_states, neg_inf)
score[0] = log_probs[0, ext[0]]
if num_states > 1:
score[1] = log_probs[0, ext[1]]
back = np.zeros((num_frames, num_states), dtype=np.int8) # 0 stay, 1 step, 2 skip
for t in range(1, num_frames):
stay = score
step = np.concatenate(([neg_inf], score[:-1]))
skip = np.concatenate(([neg_inf, neg_inf], score[:-2]))
skip = np.where(can_skip, skip, neg_inf)
stacked = np.stack([stay, step, skip])
choice = np.argmax(stacked, axis=0)
best = np.take_along_axis(stacked, choice[None, :], axis=0)[0]
score = best + log_probs[t, ext]
back[t] = choice
final_candidates = [num_states - 1] + ([num_states - 2] if num_states > 1 else [])
end = max(final_candidates, key=lambda s: score[s])
if not math.isfinite(float(score[end])):
return None
path = [0] * num_frames
state = end
for t in range(num_frames - 1, -1, -1):
path[t] = state
state -= int(back[t, state])
return path
def align_words(
tokens: Sequence[str],
log_probs: NDArray[np.float32],
vocab: Mapping[str, int],
*,
blank_token: str,
word_delimiter: str,
frame_hop_ms: float,
duration_ms: int,
) -> AlignmentResult:
"""Align transcript ``tokens`` (whitespace-split words) to CTC ``log_probs``.
Args:
tokens: Transcript words in order.
log_probs: ``[num_frames, vocab_size]`` emissions. Log-softmax is re-applied
here (idempotent on log-probabilities), so raw logits cannot inflate
``align_score``. ``-inf`` entries are allowed; NaN and ``+inf`` are not.
vocab: Token → id (characters, ``blank_token``, ``word_delimiter``).
blank_token: CTC blank (``[PAD]`` for wav2vec2 HF models).
word_delimiter: Word separator token (``|``).
frame_hop_ms: Frame stride in ms (20 for wav2vec2).
duration_ms: Segment length. ``num_frames * frame_hop_ms`` must be within
:data:`FRAME_SLACK` frames of it (catches unresampled audio); word ends are
clamped to it.
Returns:
:class:`AlignmentResult` with words carrying ``align_score`` = mean emission
probability of the word's character frames, or a failure reason
(``unalignable_chars``, ``no_alignable_text``, ``too_few_frames``, ``no_path``,
``words_beyond_audio``).
Raises:
ValueError: malformed inputs (shape, vocab ids not ``0..V-1``, NaN/+inf, bad hop,
frame count inconsistent with the duration).
"""
import numpy as np
if log_probs.ndim != 2 or log_probs.shape[1] != len(vocab):
raise ValueError(f"log_probs shape {log_probs.shape} does not match vocab {len(vocab)}")
if sorted(vocab.values()) != list(range(len(vocab))):
raise ValueError("vocab ids must be exactly 0..V-1")
if np.any(np.isnan(log_probs)) or np.any(np.isposinf(log_probs)):
raise ValueError("log_probs contain NaN or +inf")
if blank_token not in vocab or word_delimiter not in vocab:
raise ValueError("blank or word delimiter missing from vocab")
if not (math.isfinite(frame_hop_ms) and frame_hop_ms >= 1):
raise ValueError(f"frame_hop_ms must be a finite number >= 1 ms, got {frame_hop_ms!r}")
if type(duration_ms) is not int or duration_ms <= 0:
raise ValueError(f"invalid duration_ms {duration_ms!r}")
if abs(log_probs.shape[0] * frame_hop_ms - duration_ms) > FRAME_SLACK * frame_hop_ms:
raise ValueError(
f"{log_probs.shape[0]} frames x {frame_hop_ms} ms do not match {duration_ms} ms"
)
with np.errstate(invalid="ignore"):
lp = log_probs.astype(np.float64)
row_max = np.max(lp, axis=1, keepdims=True)
if not np.all(np.isfinite(row_max)):
raise ValueError("a frame has no finite log-probability")
lp = lp - (row_max + np.log(np.sum(np.exp(lp - row_max), axis=1, keepdims=True)))
special = {blank_token, word_delimiter}
char_vocab = {tok: idx for tok, idx in vocab.items() if len(tok) == 1 and tok not in special}
units = alignment_units(tokens, char_vocab)
if isinstance(units, str):
reason, _, chars = units.partition(":")
return AlignmentResult(None, reason, {"chars": chars} if chars else {})
display, targets = units
labels: list[int] = []
owner: list[int] = [] # word index for each label, -1 for delimiters
for index, target in enumerate(targets):
if index:
labels.append(vocab[word_delimiter])
owner.append(-1)
for ch in target:
labels.append(char_vocab[ch])
owner.append(index)
repeats = sum(1 for a, b in itertools.pairwise(labels) if a == b)
if log_probs.shape[0] < len(labels) + repeats:
return AlignmentResult(
None,
"too_few_frames",
{"frames": int(log_probs.shape[0]), "needed": len(labels) + repeats},
)
path = _viterbi(lp, labels, vocab[blank_token])
if path is None:
return AlignmentResult(None, "no_path")
first: list[int | None] = [None] * len(targets)
last: list[int | None] = [None] * len(targets)
probs: list[list[float]] = [[] for _ in targets]
for frame, state in enumerate(path):
if state % 2 == 0:
continue
word = owner[state // 2]
if word < 0:
continue
if first[word] is None:
first[word] = frame
last[word] = frame
probs[word].append(math.exp(float(lp[frame, labels[state // 2]])))
words: list[TimedWord] = []
for index, text in enumerate(display):
start_frame, end_frame = first[index], last[index]
if start_frame is None or end_frame is None:
return AlignmentResult(None, "no_path", {"word_index": index})
start_ms = min(round(start_frame * frame_hop_ms), duration_ms - 1)
end_ms = min(round((end_frame + 1) * frame_hop_ms), duration_ms)
end_ms = max(end_ms, start_ms + 1)
if words and start_ms < words[-1].end_ms:
# Only reachable through the end clamp (emissions slightly longer than the
# audio): words squeezed past the end cannot be timed, never let them overlap.
return AlignmentResult(None, "words_beyond_audio", {"word_index": index})
score = sum(probs[index]) / len(probs[index])
words.append(TimedWord(text, start_ms, end_ms, score))
return AlignmentResult(tuple(words), None)
|