File size: 9,557 Bytes
504fb6d 55ca644 504fb6d 55ca644 504fb6d 55ca644 504fb6d 55ca644 504fb6d 55ca644 504fb6d 55ca644 504fb6d 55ca644 504fb6d 55ca644 504fb6d | 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 | # SPDX-License-Identifier: Apache-2.0
# Copyright 2026 ayutaz
"""KoeAct-JP inference from the released files: 16 kHz speech -> gated Action JSON.
The release directory holds ``model.safetensors`` (Ralomi's input layer and blocks 1..4 as
released, the fine-tuned blocks 5..8, the CTC classifier and the decoder) and ``config.json``
(the ``p_act`` threshold). Nothing is downloaded. The outputs equal ``koeact.infer``'s torch
engine (``tests/test_runtime.py``); this module imports no training or evaluation code, so it
runs from the bundled ``code/`` directory alone (``koeact.release``).
python -m koeact.runtime --model <release dir> a.wav b.wav
python -m koeact.runtime --model <release dir> --mic
``from_device_line`` turns a CoreS3 ``ACTION ... p_act=... ids=...`` line (the firmware stops
at the decoder's piece ids) into the same gated JSON; ``follow`` does it for every recognition in
a stream of serial lines (``--serial <port>``, needs pyserial; touch the screen to record 4 s).
"""
import argparse
import json
import math
import re
from pathlib import Path
import numpy as np
from koeact.action import parse_output, to_json
from koeact.features.logmel import HOP, MAX_SECONDS, SAMPLE_RATE, WINDOW, log_mel
from koeact.model import pieces
FORMAT = "koeact-runtime-1"
FIRST_BLOCK = 4 # the decoder reads layer 4; blocks 5..8 belong to the fine-tuned head
MIN_SAMPLES = WINDOW + 3 * HOP # Ralomi needs four log-Mel frames
CLEAN_RMS_DBFS = -23.0
CLEAN_LEAD, CLEAN_TAIL = 0.5, 0.3 # seconds of silence before and after
PEAK = 0.99
PAIR = re.compile(r"(\w+)=(-?\d+(?:\.\d+)?)")
def build_net(ft_blocks: int):
"""Ralomi's lower half (input layer, blocks 1..4) and the plan B head over it."""
from torch import nn
from koeact.model.plan_b import DecoderB
from koeact.model.ralomi_torch import RalomiTorch
class KoeActNet(nn.Module):
def __init__(self) -> None:
super().__init__()
ralomi = RalomiTorch()
self.subsampling_projection = ralomi.subsampling_projection
self.input_normalization = ralomi.input_normalization
self.lower = nn.ModuleList(ralomi.blocks[:FIRST_BLOCK])
self.head = DecoderB(ralomi, ft_blocks)
return KoeActNet()
def _set_level(x: np.ndarray, dbfs: float) -> np.ndarray:
rms = float(np.sqrt(np.mean(np.square(x, dtype=np.float64)))) if len(x) else 0.0
if not 0 < rms < math.inf: # silence stays silence; NaN or inf is left as it is
return x
y = x * (10 ** ((dbfs - 20 * math.log10(rms)) / 20))
peak = float(np.abs(y).max())
return y * (PEAK / peak) if peak > PEAK else y
def clean(audio: np.ndarray) -> np.ndarray:
"""``koeact.features.augment.clean``: -23 dBFS RMS, 0.5 s of silence before, 0.3 s after."""
lead = np.zeros(round(CLEAN_LEAD * SAMPLE_RATE), np.float32)
tail = np.zeros(round(CLEAN_TAIL * SAMPLE_RATE), np.float32)
return np.concatenate([lead, _set_level(audio, CLEAN_RMS_DBFS), tail]).astype(np.float32)
_clean = clean # ``KoeAct.__call__`` has a ``clean`` parameter
def prepare(audio: np.ndarray) -> np.ndarray:
"""Cut to 15 s and pad short audio to four log-Mel frames (as the evaluations did)."""
x = audio[: MAX_SECONDS * SAMPLE_RATE]
if len(x) < MIN_SAMPLES:
x = np.pad(x, (0, MIN_SAMPLES - len(x)))
return x
def resample(x: np.ndarray, sr: int) -> np.ndarray:
if sr == SAMPLE_RATE:
return x
from math import gcd
from scipy.signal import resample_poly
g = gcd(SAMPLE_RATE, sr)
return resample_poly(x, SAMPLE_RATE // g, sr // g).astype(np.float32)
def load_audio(path: Path) -> np.ndarray:
"""Mono float32 at 16 kHz (other rates are resampled; channels are averaged)."""
import soundfile as sf
x, sr = sf.read(path, dtype="float32", always_2d=True)
return resample(x.mean(axis=1), sr)
class KoeAct:
def __init__(self, net, threshold: float) -> None:
self.net, self.threshold = net.eval(), threshold
self.grammar = pieces.ActionGrammar()
@classmethod
def from_dir(cls, path: Path | str, threads: int = 0) -> "KoeAct":
import torch
from safetensors.torch import load_file
if threads:
torch.set_num_threads(threads)
path = Path(path)
config = json.loads((path / "config.json").read_text("utf-8"))
if config.get("format") != FORMAT:
raise ValueError(f"{path}: not a {FORMAT} release ({config.get('format')!r})")
net = build_net(config["ft_blocks"])
net.load_state_dict(load_file(path / "model.safetensors"))
return cls(net, config["threshold"])
def _frames(self, features: np.ndarray):
"""``[1, steps, 2, 320]``: layer 4 (rounded to fp16, as the training cache) and zeros
for layer 8, which the head recomputes from layer 4."""
import torch
from koeact.model.ralomi_torch import SUBSAMPLING
x = torch.from_numpy(features)[None]
steps = x.shape[1] // SUBSAMPLING
h = x[:, : steps * SUBSAMPLING].reshape(1, steps, -1)
h = torch.relu(self.net.input_normalization(self.net.subsampling_projection(h)))
for block in self.net.lower:
h = block(h)
h4 = h[0].numpy()
frames = np.stack([h4, np.zeros_like(h4)], axis=1).astype(np.float16)
return torch.from_numpy(frames.astype(np.float32))[None]
def __call__(self, audio: np.ndarray, clean: bool = True) -> dict:
"""One utterance (float32, 16 kHz) -> ``output`` (gated JSON), ``raw``, ``p_act``.
``clean`` applies the training preprocessing first (v0.1.0 did not)."""
import torch
from koeact.model import decoder_a2
if clean:
audio = _clean(audio)
with torch.no_grad():
x = self._frames(log_mel(prepare(audio)))
mask = torch.ones(x.shape[:2], dtype=torch.bool)
d = decoder_a2.greedy(self.net.head, x, mask, self.grammar)[0]
return gate(to_json(d.calls), round(d.p_act, 6), self.threshold)
def gate(raw: str, p_act: float, threshold: float) -> dict:
accepted = p_act >= threshold # below the threshold the answer is "[]"
return {"output": raw if accepted else "[]", "raw": raw, "p_act": p_act,
"accepted": accepted} # fmt: skip
def from_device_line(line: str, threshold: float) -> dict:
"""A CoreS3 ``ACTION``/``A2`` line (``p_act=`` and the piece ids after ``ids=``) -> JSON."""
head, _, ids = line.partition(" ids=")
fields = dict(PAIR.findall(head))
calls = parse_output(pieces.decode_text([int(i) for i in ids.split()])).calls or []
return gate(to_json(calls), float(fields["p_act"]), threshold)
def follow(lines, threshold: float):
"""Gated JSON for each microphone recognition (``A2 ... real=1 ... ids=``) or ``ACTION`` line;
``real=0`` lines are timings over stand-in frames and are skipped."""
for line in lines:
if " ids=" not in line:
continue
if line.startswith("ACTION ") or (line.startswith("A2 ") and " real=1 " in line):
yield from_device_line(line, threshold)
def serial_lines(port: str):
import serial
with serial.Serial(port, 115200, timeout=1) as s:
while True:
raw = s.readline()
if raw:
yield raw.decode("utf-8", "replace").rstrip()
def record_utterance() -> np.ndarray:
"""One utterance from the default microphone: Enter starts, Enter stops."""
import sounddevice as sd
sr = int(sd.query_devices(kind="input")["default_samplerate"])
chunks: list[np.ndarray] = []
with sd.InputStream(samplerate=sr, channels=1, dtype="float32",
callback=lambda data, *_: chunks.append(data[:, 0].copy())): # fmt: skip
input(" ● 話してください(終わったら Enter) / speak, then press Enter")
return resample(np.concatenate(chunks) if chunks else np.zeros(0, np.float32), sr)
def main() -> None:
p = argparse.ArgumentParser(description=__doc__.splitlines()[0])
p.add_argument("wav", nargs="*", type=Path)
p.add_argument("--model", type=Path, default=Path("."), help="the release directory")
p.add_argument("--threads", type=int, default=0)
p.add_argument("--mic", action="store_true", help="Enter starts and stops; q quits")
p.add_argument(
"--no-clean",
action="store_true",
help="feed the audio as is (v0.1.0); the default levels it and pads silence",
)
p.add_argument("--serial", help="a CoreS3 with the KoeAct firmware (reads config.json only)")
args = p.parse_args()
if args.serial:
threshold = json.loads((args.model / "config.json").read_text("utf-8"))["threshold"]
for r in follow(serial_lines(args.serial), threshold):
print(json.dumps(r, ensure_ascii=False), flush=True)
return
model = KoeAct.from_dir(args.model, args.threads)
if args.mic:
while input("Enter で録音(q で終了) / Enter to record, q to quit > ").strip() != "q":
r = model(record_utterance(), clean=not args.no_clean)
print(f" {r['output']} (p_act {r['p_act']:.3f})")
return
for w in args.wav:
x = load_audio(w)
r = model(x, clean=not args.no_clean)
print(json.dumps({"file": str(w), "output": r["output"], "p_act": r["p_act"],
"accepted": r["accepted"]}, ensure_ascii=False), flush=True) # fmt: skip
if __name__ == "__main__":
main()
|