mateli-punct-onnx / punctuate.py
samuli's picture
Initial release
131419f verified
Raw History Blame Contribute Delete
10.9 kB
"""Finnish punctuation and casing for speech recognition output (Mateli-punct, ONNX Runtime).
python punctuate.py "mä luulen et se on ihan hyvä juttu mut en mä tiiä mitä mieltä sä oot"
from punctuate import Punctuator
p = Punctuator(".") # directory with config.json, tokenizer.json, *.onnx
p.punctuate("mä luulen et se on ihan hyvä juttu") # -> "Mä luulen, et se on ihan hyvä juttu."
p.punctuate(words, pauses=[0.05, 0.4, ...]) # pauses[i] = silence after words[i] in seconds
stream = p.stream() # live use, one word at a time
for word, pause_before in asr_words:
print(stream.push(word, pause_before), end=" ") # words come out once 3 words follow them
print(stream.flush())
Words are never changed, added or removed: each word gets one label, the punctuation after it
(none , . ? !) and its casing (lower, Capitalised, ALL CAPS). Pauses are optional; they help
mostly with sentence boundaries. Needs numpy, onnxruntime and tokenizers.
"""
from __future__ import annotations
import argparse
import bisect
import json
import math
from pathlib import Path
import numpy as np
import onnxruntime as ort
from tokenizers import Tokenizer
SENTENCE_END = {".", "?", "!"}
class Model:
"""input ids -> logits [seq, labels]."""
def __init__(self, model_dir: Path, precision: str = "int8", threads: int = 1):
cfg = json.loads((model_dir / "config.json").read_text(encoding="utf-8"))
so = ort.SessionOptions()
so.intra_op_num_threads = threads
so.log_severity_level = 3
self.sess = ort.InferenceSession(str(model_dir / cfg["files"][precision]), so,
providers=["CPUExecutionProvider"])
self.cfg = cfg
def __call__(self, ids: list[int]) -> np.ndarray:
x = np.array([ids], np.int64)
return self.sess.run(["logits"], {"input_ids": x, "attention_mask": np.ones_like(x)})[0][0]
class Punctuator:
def __init__(self, model_dir=".", precision: str = "int8", threads: int = 1,
window: int = 150, overlap: int = 50):
d = Path(model_dir)
self.model = Model(d, precision, threads)
cfg = self.model.cfg
self.labels = [cfg["id2label"][str(i)] for i in range(len(cfg["id2label"]))]
self.tok = Tokenizer.from_file(str(d / "tokenizer.json"))
pm = cfg["pause_markers"]
self.edges = pm["edges"]
self.flag_id = self.tok.token_to_id(pm["flag"])
self.bucket_ids = [self.tok.token_to_id(b) for b in pm["buckets"]]
self.cls_id, self.sep_id = self.tok.token_to_id("[CLS]"), self.tok.token_to_id("[SEP]")
self.unk_id = self.tok.token_to_id("[UNK]")
if window < 1 or not 0 <= overlap < window:
raise ValueError("need window >= 1 and 0 <= overlap < window")
self.window, self.overlap = window, overlap
# ------------------------------------------------------------------ one model call
def _encode(self, words, pauses):
"""[CLS] (flag) w1 pieces (pause marker) w2 pieces ... [SEP]; returns ids and the word
index of each position (-1 for special tokens). The flag says pauses are given; a marker
follows a word whose pause is at least the first bucket edge (unknown pause: no marker)."""
ids, widx = [self.cls_id], [-1]
if pauses is not None:
ids.append(self.flag_id)
widx.append(-1)
for w, word in enumerate(words):
pieces = self.tok.encode(word, add_special_tokens=False).ids or [self.unk_id]
ids += pieces
widx += [w] * len(pieces)
p = None if pauses is None else pauses[w]
if p is not None and not math.isnan(p):
b = bisect.bisect_right(self.edges, p)
if b > 0:
ids.append(self.bucket_ids[b - 1])
widx.append(-1)
return ids[:511] + [self.sep_id], widx[:511] + [-1]
def _call(self, words, pauses) -> list[int]:
"""Label id of each word (the prediction on its first sub-token); -1 if truncated away."""
ids, widx = self._encode(words, pauses)
logits = self.model(ids)
out = [-1] * len(words)
for i, w in enumerate(widx):
if w >= 0 and out[w] < 0:
out[w] = int(np.argmax(logits[i]))
return out
# ------------------------------------------------------------------ whole texts
def predict(self, words: list[str], pauses: list[float] | None = None) -> list[str]:
"""One label per word ("O", ",|U", ".", ...). Long inputs run in overlapping windows of
`window` words; each word keeps the prediction from the window where it has the most
context on both sides."""
n = len(words)
if pauses is not None and len(pauses) != n:
raise ValueError("give one pause per word (the silence after it)")
best = [(-1, -1)] * n # (context on the nearer side, label id)
def run(s):
chunk = words[s:s + self.window]
labels = self._call(chunk, None if pauses is None else pauses[s:s + self.window])
for i, lab in enumerate(labels):
if lab < 0: # cut off by the 512-token limit
continue
left = i if s > 0 else n
right = len(chunk) - 1 - i if s + len(chunk) < n else n
if min(left, right) > best[s + i][0]:
best[s + i] = (min(left, right), lab)
stride = self.window - self.overlap
for s in (range(0, max(1, n - self.overlap), stride) if n > self.window else [0]):
run(s)
while (i := next((i for i, (_, lab) in enumerate(best) if lab < 0), None)) is not None:
run(i) # words that very long windows lost to truncation
if best[i][1] < 0:
raise RuntimeError(f"word {i} does not fit into the model input")
return [self.labels[lab] for _, lab in best]
def punctuate(self, text_or_words, pauses: list[float] | None = None) -> str:
words = text_or_words.split() if isinstance(text_or_words, str) else list(text_or_words)
return render(words, self.predict(words, pauses))
def stream(self, **kw) -> "StreamingPunctuator":
return StreamingPunctuator(self, **kw)
def render(words: list[str], labels: list[str], capitalise_first: bool = True) -> str:
"""Apply labels; also capitalise the first word and every word after . ? !"""
if len(words) != len(labels):
raise ValueError(f"{len(words)} words but {len(labels)} labels")
out, prev = [], "." if capitalise_first else "O"
for w, lab in zip(words, labels):
punct, _, case = lab.partition("|")
if case == "A":
w = w.upper()
elif case == "U" or prev in SENTENCE_END:
w = w[:1].upper() + w[1:]
out.append(w if punct == "O" else w + punct)
prev = punct
return " ".join(out)
class StreamingPunctuator:
"""Live punctuation, one word at a time. A word is committed once `lookahead` later words are
known; the model sees up to `context` committed words before the pending ones and runs at
most every `every` words. With the defaults a word appears about 2.4 s after it is spoken
(at a typical speaking rate) and boundary F1 is within 2 points of whole-text punctuation."""
def __init__(self, p: Punctuator, lookahead: int = 3, context: int = 64, every: int = 5):
if lookahead < 0 or context < 0 or every < 1:
raise ValueError("need lookahead >= 0, context >= 0 and every >= 1")
self.p, self.k, self.L, self.every = p, lookahead, context, every
self.context, self.pending, self.pauses = [], [], [] # pauses: after each context+pending word
self.new, self.prev = 0, "."
def push(self, word: str, pause_before: float | None = None) -> str:
"""Add a word; pause_before is the silence since the previous word (None = no pauses).
Returns the newly committed text ("" if nothing is committed yet)."""
if self.pauses:
self.pauses[-1] = math.nan if pause_before is None else pause_before
self.pauses.append(math.nan) # the newest word's pause is not known yet
self.pending.append(word)
self.new += 1
if len(self.pending) <= self.k or self.new < self.every:
return ""
return self._run(final=False)
def flush(self, pause_after: float | None = None) -> str:
"""Commit everything still pending; pause_after is the silence after the last word, if known."""
if self.pending and pause_after is not None:
self.pauses[-1] = pause_after
parts = []
while self.pending:
text = self._run(final=True)
if not text: # the context left no room: drop it and retry
if not self.context:
raise RuntimeError("cannot fit the next word into the model input")
self.pauses = self.pauses[len(self.context):]
self.context = []
continue
parts.append(text)
return " ".join(parts)
def _run(self, final: bool) -> str:
words = self.context + self.pending
use_pauses = any(not math.isnan(x) for x in self.pauses)
labels = self.p._call(words, self.pauses if use_pauses else None)[len(self.context):]
covered = next((i for i, lab in enumerate(labels) if lab < 0), len(labels)) # < len if truncated
n = covered if final else max(0, covered - self.k)
committed = self.pending[:n]
text = render(committed, [self.p.labels[lab] for lab in labels[:n]], capitalise_first=False)
if n: # the casing rule across calls: capitalise after a sentence end
if self.prev in SENTENCE_END:
text = text[:1].upper() + text[1:]
self.prev = self.p.labels[labels[n - 1]].partition("|")[0]
self.context = (self.context + committed)[-self.L:] if self.L else []
self.pending = self.pending[n:]
self.pauses = self.pauses[len(self.pauses) - len(self.context) - len(self.pending):]
self.new = 0
return text
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("text", help="lowercase words without punctuation")
ap.add_argument("--model-dir", default=str(Path(__file__).resolve().parent))
ap.add_argument("--fp32", action="store_true", help="use model.onnx instead of model.int8.onnx")
a = ap.parse_args()
print(Punctuator(a.model_dir, "fp32" if a.fp32 else "int8").punctuate(a.text))
if __name__ == "__main__":
main()