File size: 14,258 Bytes
3dd0f09 | 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 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 | """Mateli: streaming Finnish speech recognition (MinGRU encoder + RNN-T), PyTorch reference.
python mateli.py speech.wav # text
python mateli.py speech.wav --words # one word per line with start/end times
from mateli import Mateli, load_audio
asr = Mateli.from_pretrained(".") # directory with config.json and model.safetensors
result = asr.transcribe(load_audio("speech.wav"))
print(result.text)
The encoder is chunk-causal: audio is processed in 320 ms chunks (32 log-mel frames -> 8 encoder
frames of 40 ms), each chunk sees only itself and the past, and the state carried between chunks
has a fixed size. transcribe() runs the same chunk loop a live stream would, so its output equals
streaming recognition of the same audio.
Model: log-mel (80 bins, 25 ms window, 10 ms hop) -> two stride-2 Conv2d -> 16 macaron blocks
(feed-forward / MinGRU / causal depthwise conv / feed-forward), with a windowed self-attention
sublayer after blocks 4, 7, 10 and 13 (keys = the current chunk + 64 previous frames) -> additive
joiner with a one-layer MinGRU prediction network over 44 characters. Greedy decoding.
"""
from __future__ import annotations
import argparse
import json
import math
from dataclasses import dataclass
from pathlib import Path
import torch
import torch.nn as nn
import torch.nn.functional as F
# --------------------------------------------------------------------------- MinGRU
class MinGRU(nn.Module):
"""minGRU (Feng et al. 2024, "Were RNNs All We Needed?") with a sigmoid highway.
h_t = (1 - z_t) * h_{t-1} + z_t * g(x_t), computed for a whole chunk with a log-space scan."""
def __init__(self, hidden: int):
super().__init__()
self.layers = nn.ModuleList([nn.Linear(hidden, 3 * hidden, bias=False)])
@staticmethod
def _log_g(x):
return torch.where(x >= 0, (F.relu(x) + 0.5).log(), -F.softplus(-x))
def forward(self, x, state):
"""x [B, T, H], state [B, H] (>= 0) -> (output [B, T, H], new state [B, H])."""
hidden, gate, proj = self.layers[0](x).chunk(3, dim=-1)
log_coeffs = -F.softplus(gate)
log_values = -F.softplus(-gate) + self._log_g(hidden)
# the carried state enters the scan as an extra first step with coefficient 1
log_coeffs = torch.cat([torch.zeros_like(log_coeffs[:, :1]), log_coeffs], dim=1)
log_values = torch.cat([state.clamp_min(0).log().unsqueeze(1), log_values], dim=1)
a_star = log_coeffs.cumsum(dim=1)
h = (a_star + (log_values - a_star).logcumsumexp(dim=1)).exp()[:, 1:]
g = proj.sigmoid()
return g * h + (1.0 - g) * x, h[:, -1]
def step(self, x, state):
"""One time step without the scan: x [B, H], state [B, H] -> (output, new state)."""
hidden, gate, proj = self.layers[0](x).chunk(3, dim=-1)
g_hidden = torch.where(hidden >= 0, hidden + 0.5, hidden.sigmoid())
h = torch.lerp(state, g_hidden, gate.sigmoid())
g = proj.sigmoid()
return g * h + (1.0 - g) * x, h
# --------------------------------------------------------------------------- encoder
class FeedForward(nn.Module):
def __init__(self, d: int, expansion: int):
super().__init__()
self.net = nn.Sequential(nn.LayerNorm(d), nn.Linear(d, expansion * d), nn.SiLU(),
nn.Linear(expansion * d, d))
def forward(self, x):
return self.net(x)
class MinGRUMixer(nn.Module):
def __init__(self, d: int):
super().__init__()
self.norm = nn.LayerNorm(d)
self.gru = MinGRU(d)
def forward(self, x, state):
return self.gru(self.norm(x), state)
class ConvModule(nn.Module):
"""Conformer convolution module with a causal depthwise convolution."""
def __init__(self, d: int, kernel: int):
super().__init__()
self.norm = nn.LayerNorm(d)
self.pw1 = nn.Conv1d(d, 2 * d, 1)
self.dw = nn.Conv1d(d, d, kernel, groups=d)
self.act_norm = nn.LayerNorm(d)
self.pw2 = nn.Conv1d(d, d, 1)
def forward(self, x, cache):
"""x [B, T, D], cache [B, D, kernel-1] (the previous GLU outputs)."""
y = F.glu(self.pw1(self.norm(x).transpose(1, 2)), dim=1)
y = torch.cat([cache, y], dim=2)
new_cache = y[:, :, -cache.shape[2]:]
y = F.silu(self.act_norm(self.dw(y).transpose(1, 2)))
return self.pw2(y.transpose(1, 2)).transpose(1, 2), new_cache
class Block(nn.Module):
def __init__(self, d: int, kernel: int, expansion: int):
super().__init__()
self.ff1 = FeedForward(d, expansion)
self.mixer = MinGRUMixer(d)
self.conv = ConvModule(d, kernel)
self.ff2 = FeedForward(d, expansion)
self.norm = nn.LayerNorm(d)
def forward(self, x, state):
gru_h, conv_cache = state
x = x + 0.5 * self.ff1(x)
m, gru_h = self.mixer(x, gru_h)
x = x + m
c, conv_cache = self.conv(x, conv_cache)
x = x + c
x = x + 0.5 * self.ff2(x)
return self.norm(x), (gru_h, conv_cache)
class _Attn(nn.Module):
def __init__(self, d: int, heads: int):
super().__init__()
self.norm = nn.LayerNorm(d)
self.mha = nn.MultiheadAttention(d, heads, batch_first=True)
class WindowedAttention(nn.Module):
"""Residual self-attention: the chunk's frames attend to the chunk and up to `left` cached
earlier inputs. No positional encoding (the causal convolutions provide position)."""
def __init__(self, d: int, heads: int, left: int):
super().__init__()
self.attn = _Attn(d, heads)
self.left = left
def forward(self, x, cache):
ctx = torch.cat([cache, x], dim=1)
q, kv = self.attn.norm(x), self.attn.norm(ctx)
y, _ = self.attn.mha(q, kv, kv, need_weights=False)
return x + y, ctx[:, -self.left:]
class Encoder(nn.Module):
def __init__(self, cfg: dict):
super().__init__()
d = cfg["d_model"]
self.blocks = nn.ModuleList([Block(d, cfg["conv_kernel"], cfg["ffn_expansion"])
for _ in range(cfg["n_blocks"])])
self.attn = nn.ModuleDict({str(p): WindowedAttention(d, cfg["attn_heads"], cfg["attn_left_context"])
for p in cfg["attn_after_blocks"]})
def initial_state(self, device):
d, k = self.blocks[0].norm.normalized_shape[0], self.blocks[0].conv.dw.kernel_size[0]
return {"blocks": [(torch.zeros(1, d, device=device), torch.zeros(1, d, k - 1, device=device))
for _ in self.blocks],
"attn": {p: torch.zeros(1, 0, d, device=device) for p in self.attn}}
def forward(self, x, state):
new_blocks, new_attn = [], {}
for i, blk in enumerate(self.blocks):
x, st = blk(x, state["blocks"][i])
new_blocks.append(st)
p = str(i + 1)
if p in self.attn:
x, new_attn[p] = self.attn[p](x, state["attn"][p])
return x, {"blocks": new_blocks, "attn": new_attn}
# --------------------------------------------------------------------------- front end
class LogMel(nn.Module):
def __init__(self, n_mels=80, n_fft=512, win=400, hop=160):
super().__init__()
self.n_fft, self.win, self.hop = n_fft, win, hop
self.register_buffer("fb", torch.zeros(n_mels, n_fft // 2 + 1)) # loaded from the checkpoint
self.register_buffer("window", torch.hann_window(win))
def forward(self, wav):
"""[B, samples] -> [B, frames, n_mels], frames = 1 + samples // hop."""
spec = torch.stft(wav, self.n_fft, self.hop, win_length=self.win, window=self.window,
center=True, return_complex=True)
mel = torch.einsum("mf,bft->bmt", self.fb, spec.real ** 2 + spec.imag ** 2)
return torch.log(mel + 1e-6).transpose(1, 2)
class Subsampler(nn.Module):
"""Two Conv2d (3x3, stride 2) over (time, mel) -> 40 ms frames."""
def __init__(self, n_mels: int, d: int):
super().__init__()
self.conv = nn.Sequential(nn.Conv2d(1, d, 3, stride=2, padding=1), nn.ReLU(),
nn.Conv2d(d, d, 3, stride=2, padding=1), nn.ReLU())
f_out = ((n_mels - 1) // 2 + 1 - 1) // 2 + 1
self.out = nn.Linear(d * f_out, d)
def forward(self, mel):
x = self.conv(mel.unsqueeze(1)) # [B, d, T/4, F/4]
b, c, t, f = x.shape
return self.out(x.transpose(1, 2).reshape(b, t, c * f))
class FrontEnd(nn.Module):
def __init__(self, n_mels: int, d: int):
super().__init__()
self.logmel = LogMel(n_mels)
self.subsample = Subsampler(n_mels, d)
# --------------------------------------------------------------------------- transducer
class PredictionNetwork(nn.Module):
def __init__(self, vocab_size: int, hidden: int):
super().__init__()
self.embed = nn.Embedding(vocab_size, hidden)
self.rnn = MinGRU(hidden)
def step(self, token: int, state):
return self.rnn.step(self.embed(torch.tensor([token], device=state.device)), state)
class Joiner(nn.Module):
def __init__(self, enc_dim: int, pred_dim: int, joint_dim: int, vocab_size: int):
super().__init__()
self.enc_proj = nn.Linear(enc_dim, joint_dim)
self.pred_proj = nn.Linear(pred_dim, joint_dim)
self.out = nn.Linear(joint_dim, vocab_size)
@dataclass(frozen=True)
class Word:
text: str
start: float # seconds, time of the first character's encoder frame
end: float # seconds, time of the last character's encoder frame
@dataclass
class Transcript:
words: list[Word]
@property
def text(self) -> str:
return " ".join(w.text for w in self.words)
class Mateli(nn.Module):
def __init__(self, cfg: dict):
super().__init__()
self.cfg = cfg
d = cfg["d_model"]
self.frontend = FrontEnd(cfg["n_mels"], d)
self.encoder = Encoder(cfg)
self.prednet = PredictionNetwork(len(cfg["vocab"]), cfg["pred_hidden"])
self.joiner = Joiner(d, cfg["pred_hidden"], cfg["joint_dim"], len(cfg["vocab"]))
@classmethod
def from_pretrained(cls, model_dir=".", device: str | None = None) -> "Mateli":
from safetensors.torch import load_file
d = Path(model_dir)
model = cls(json.loads((d / "config.json").read_text(encoding="utf-8")))
model.load_state_dict(load_file(str(d / "model.safetensors")))
device = device or ("cuda" if torch.cuda.is_available() else "cpu")
return model.to(device).eval()
@property
def device(self):
return self.joiner.out.weight.device
@torch.inference_mode()
def transcribe(self, samples) -> Transcript:
"""float32 mono 16 kHz samples in [-1, 1] -> words with times."""
cfg = self.cfg
wav = torch.as_tensor(samples, dtype=torch.float32, device=self.device)
if wav.ndim != 1:
raise ValueError(f"expected mono samples of shape [n], got {tuple(wav.shape)}; mix channels down first")
wav = wav[None]
if wav.shape[1] <= self.frontend.logmel.n_fft // 2:
return Transcript([])
mel = self.frontend.logmel(wav) # [1, M, 80]
n_chunk, ctx = cfg["chunk_mel_frames"], 4 # 4 mel frames = one 40 ms frame (total stride 4)
state = self.encoder.initial_state(self.device)
blank, space = cfg["blank_id"], cfg["space_id"]
pred, h = self.prednet.step(blank, torch.zeros(1, cfg["pred_hidden"], device=self.device))
pred_proj = self.joiner.pred_proj(pred)
words, chars, t0, t1, frame = [], "", 0, 0, 0
fs = cfg["frame_seconds"]
for start in range(0, mel.shape[1], n_chunk):
# the subsampler sees the chunk plus 4 earlier mel frames; the first output frame,
# which only exists because of that context, is dropped
lo = max(0, start - ctx)
x = self.frontend.subsample(mel[:, lo:start + n_chunk])
x = x[:, 1:] if lo < start else x
enc, state = self.encoder(x, state)
enc_proj = self.joiner.enc_proj(enc)[0]
for e in enc_proj:
for _ in range(cfg["max_symbols_per_frame"]):
k = int(self.joiner.out(torch.tanh(e + pred_proj[0])).argmax())
if k == blank:
break
if k == space:
if chars:
words.append(Word(chars, round((t0 + 1) * fs, 2), round((t1 + 1) * fs, 2)))
chars = ""
else:
if not chars:
t0 = frame
chars, t1 = chars + cfg["vocab"][k], frame
pred, h = self.prednet.step(k, h)
pred_proj = self.joiner.pred_proj(pred)
frame += 1
if chars:
words.append(Word(chars, round((t0 + 1) * fs, 2), round((t1 + 1) * fs, 2)))
return Transcript(words)
def load_audio(path, sample_rate: int = 16000):
import numpy as np
import soundfile as sf
x, sr = sf.read(str(path), dtype="float32", always_2d=True)
x = x.mean(axis=1)
if sr != sample_rate:
from scipy.signal import resample_poly
g = math.gcd(sr, sample_rate)
x = resample_poly(x, sample_rate // g, sr // g).astype(np.float32)
return x
def main():
ap = argparse.ArgumentParser(description=__doc__.split("\n")[0])
ap.add_argument("audio")
ap.add_argument("--model-dir", default=str(Path(__file__).resolve().parent))
ap.add_argument("--device", default=None)
ap.add_argument("--words", action="store_true", help="print words with start and end times")
a = ap.parse_args()
asr = Mateli.from_pretrained(a.model_dir, a.device)
t = asr.transcribe(load_audio(a.audio, asr.cfg["sample_rate"]))
if a.words:
for w in t.words:
print(f"{w.start:7.2f} {w.end:7.2f} {w.text}")
else:
print(t.text)
if __name__ == "__main__":
main()
|