JoelAjitesh's picture
Phoneme wake word engine: student+teacher models, INT8 export, C engine, enrollment tooling
f6aec75 verified
Raw
History Blame Contribute Delete
11.1 kB
"""Streaming keyword spotting over CTC phoneme posteriors.
Keyword-filler decoding with two robustness measures beyond the textbook
version (both port directly to C on the ESP32):
- minimum phone duration: every phone is expanded to two chained states,
so an alignment must spend >= 2 frames (40 ms) per phone. Kills
spurious single-frame matches.
- duration-normalized scoring: each Viterbi token carries the frame at
which its path entered the keyword, and the detection statistic is
(keyword_score - filler_score) / path_duration -- an average
per-frame deficit, comparable across phrase lengths and speaking
rates. Alignments faster than 2 frames/phone or slower than
MAX_FRAMES_PER_PHONE are rejected outright.
Enrollment of a new wake word is just: phones = text_to_phones(phrase).
State chain per phone i: A_i -> B_i (same label), optional blank after.
"""
import numpy as np
from .phones import BLANK, PHONE_TO_ID, text_to_phones
NEG_INF = -1e30
# 200 ms per phone upper bound: fluent speech runs 40-120 ms/phone, and
# looser caps let garbage alignments crawl across continuous speech
# (observed 1.5-2.3 s "matches" on background TV at 400 ms/phone)
MAX_FRAMES_PER_PHONE = 10
# Highly confusable phone pairs: an alignment may match either member.
# Text pronunciations (CMUdict/G2P) use canonical vowels, but real and TTS
# speech often reduces them -- e.g. "orbit" is listed as AO R B AH T yet
# actually said as AO R B IH T. Without this, a sharper acoustic model
# *punishes* the mismatch harder.
CONFUSABLE = {
"AH": ("AH", "IH", "ER"), # schwa reduces/r-colors: orbit -> orbERt
"IH": ("IH", "AH"),
"ER": ("ER", "AH"),
"AO": ("AO", "AA"), # cot-caught merger and accent variation
"AE": ("AE", "AA"), # trap-father variation (sakura, tanaka)
"UH": ("UH", "UW"), # lax/tense u (book/boot neighbors)
"K": ("K", "G"), # stops voice between vowels: nakuma->naguma
"G": ("G", "K"),
"T": ("T", "D"), # also covers tapped/rolled R heard as D
"D": ("D", "T"),
}
class KeywordSpotter:
def __init__(self, phrase, threshold=-4.4, refractory_frames=50,
strong_margin=1.5, strong_ratio=0.5, phones=None,
wildcards=None, wildcard_penalty=2.2):
"""threshold: average per-frame deficit tolerated along the keyword
path (log-prob units, <= 0; closer to 0 = stricter).
refractory_frames: minimum output frames (20 ms) between fires.
strong_margin/strong_ratio: evidence requirement -- at least
strong_ratio of the path's phone frames must have their phone
within strong_margin log-prob of the frame's best class. Prevents
noisy/mushy audio (where nothing is clearly heard) from firing on
average-score alone.
phones: explicit phoneme sequence (e.g. from voice enrollment);
overrides the dictionary pronunciation of `phrase`."""
if phones is None:
phones = [p for p in text_to_phones(phrase) if p in PHONE_TO_ID]
else:
phones = [p for p in phones if p in PHONE_TO_ID]
if len(phones) < 2:
raise ValueError(f"phrase too short to spot: {phrase!r}")
self.phrase = phrase
self.phones = phones
self.threshold = threshold
self.refractory_frames = refractory_frames
self.strong_margin = strong_margin
self.strong_ratio = strong_ratio
# Mismatch tolerance (default OFF): measured on universal clips
# vs negatives, wildcarding lifted impostor scores as much as
# genuine ones and REDUCED recall at zero-FA operating points
# (vucano negmax -4.14 -> -2.89). Kept only for future
# per-count-lattice experiments.
if wildcards is None:
wildcards = 0
self.max_wild = wildcards * 2 # budget in frames
self.wild_pen = wildcard_penalty
# Build states: leading blank, then per phone A,B (+ trailing blank)
labels = [BLANK]
self.preds = [[0]] # predecessor state ids (self-loop
entry = [True] # implied for every state)
for i, p in enumerate(phones):
pid = PHONE_TO_ID[p]
a = len(labels)
if i == 0:
labels.append(pid); self.preds.append([0]); entry.append(True)
else:
pre = [a - 1] # blank between phones
if labels[a - 2] != pid:
pre.append(a - 2) # skip blank (different phones only)
labels.append(pid); self.preds.append(pre); entry.append(False)
labels.append(pid) # B state: only from A
self.preds.append([a]); entry.append(False)
labels.append(BLANK) # blank after phone
self.preds.append([a + 1]); entry.append(False)
self.labels = labels
# allowed emission ids per state (confusable vowels match either)
self.allowed = []
id_to_phone = {v: k for k, v in PHONE_TO_ID.items()}
for lab in labels:
if lab == BLANK:
self.allowed.append((BLANK,))
else:
ph = id_to_phone[lab]
self.allowed.append(tuple(
PHONE_TO_ID[p] for p in CONFUSABLE.get(ph, (ph,))))
self.entry = entry
self.n_states = len(labels)
self.finals = [self.n_states - 1, self.n_states - 2]
# min: ~60 ms per phone on average (individual phones may be
# shorter); max: 400 ms per phone. Anything outside is not a
# human saying the phrase.
self.min_dur = 3 * len(phones)
self.max_dur = MAX_FRAMES_PER_PHONE * len(phones)
self.reset()
def reset(self):
self.rel = np.full(self.n_states, NEG_INF)
self.start = np.zeros(self.n_states, dtype=np.int64)
self.pframes = np.zeros(self.n_states, dtype=np.int64)
self.strong = np.zeros(self.n_states, dtype=np.int64)
self.wild = np.zeros(self.n_states, dtype=np.int64)
self.cooldown = 0
self.t = 0
def _update(self, log_probs_frame):
"""One Viterbi DP step. Returns the best duration-valid normalized
score at a final state this frame (or None). No side effects on
detection state."""
lp = log_probs_frame
filler = float(lp.max())
prev_rel, prev_start = self.rel, self.start
prev_pf, prev_strong = self.pframes, self.strong
prev_wild = self.wild
cur_rel = np.full(self.n_states, NEG_INF)
cur_start = np.zeros(self.n_states, dtype=np.int64)
cur_pf = np.zeros(self.n_states, dtype=np.int64)
cur_strong = np.zeros(self.n_states, dtype=np.int64)
cur_wild = np.zeros(self.n_states, dtype=np.int64)
for s in range(self.n_states):
best = prev_rel[s]
best_start, best_pf, best_sf, best_w = (
prev_start[s], prev_pf[s], prev_strong[s], prev_wild[s])
for q in self.preds[s]:
if prev_rel[q] > best:
best = prev_rel[q]
best_start, best_pf, best_sf, best_w = (
prev_start[q], prev_pf[q], prev_strong[q],
prev_wild[q])
if self.entry[s] and 0.0 >= best:
# (re)start the keyword here; >= keeps the start time fresh
# while idling in silence, so duration stays meaningful
best, best_start, best_pf, best_sf, best_w = \
0.0, self.t, 0, 0, 0
emit = max(float(lp[i]) for i in self.allowed[s])
is_phone = self.labels[s] != BLANK
used_wild = 0
if is_phone and best_w < self.max_wild:
# mismatch tolerance: accept the frame's best class at a
# penalty when the expected phone isn't there (bounded
# budget converts exact matching into similarity matching)
soft = filler - self.wild_pen
if soft > emit:
emit = soft
used_wild = 1
cur_rel[s] = best + emit - filler
cur_start[s] = best_start
cur_pf[s] = best_pf + (1 if is_phone else 0)
cur_strong[s] = best_sf + (
1 if is_phone and emit >= filler - self.strong_margin else 0)
cur_wild[s] = best_w + used_wild
self.rel, self.start = cur_rel, cur_start
self.pframes, self.strong = cur_pf, cur_strong
self.wild = cur_wild
self.t += 1
norm_best = None
for s in self.finals:
dur = self.t - cur_start[s]
pf = cur_pf[s]
if not (self.min_dur <= dur <= self.max_dur):
continue
if pf < 2 * len(self.phones):
continue
# evidence requirement: the phrase's phones must actually have
# been the near-top hypothesis for enough of the match, not
# merely "not too costly on average" (mushy noisy audio)
if cur_strong[s] < self.strong_ratio * pf:
continue
# normalize by phone frames only: time spent in blank states is
# free in silence, so counting it would let blank-padded paths
# dilute their deficit below any threshold
norm = cur_rel[s] / pf
if norm_best is None or norm > norm_best[0]:
norm_best = (float(norm), int(dur))
return norm_best
def step(self, log_probs_frame):
"""Consume one frame of log-probs (C,). Returns (score, duration)
if the keyword fired this frame, else None. duration is in output
frames (20 ms each), for energy-gating by the caller."""
norm = self._update(log_probs_frame)
if self.cooldown > 0:
self.cooldown -= 1
return None
if norm is not None and norm[0] > self.threshold:
self.cooldown = self.refractory_frames
self.rel = np.full(self.n_states, NEG_INF)
return norm
return None
def run(self, log_probs):
"""Offline helper: (T, C) log-probs -> list of (frame, score)."""
hits = []
for t in range(log_probs.shape[0]):
s = self.step(log_probs[t])
if s is not None:
hits.append((t, s[0]))
return hits
def best_score(self, log_probs):
"""Max normalized final-state score over a clip (pure Viterbi, no
firing/reset side effects) -- used for calibration."""
self.reset()
best = -np.inf
for t in range(log_probs.shape[0]):
s = self._update(log_probs[t])
if s is not None and s[0] > best:
best = s[0]
return best