File size: 10,679 Bytes
582aab9 | 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 | """Code-switch text front end (pipeline step, NOT native model capability).
Turns a plain sentence + a code-switch request ("mostly English, Igbo greetings") into language-tagged text
(<eng>..</eng> <ibo>..</ibo>) that the TTS then speaks. Uses a permissively licensed text LLM:
default Qwen/Qwen3-14B (Apache-2.0), thinking disabled. N-ATLaS was not used: its terms cap use at 1,000 active
users and require a commercial licence beyond that.
Safety rails (MADLAD looped in Flash): max_new_tokens tied to input length, repetition_penalty, no_repeat_ngram,
output validation (tags only from allowed set, full coverage, length ratio, n-gram repetition cap), up to 2 retries,
then a safe fallback (the input as single-language text) with a warning.
"""
import re, os, json
from collections import Counter
LANGS = {"eng": "English", "ibo": "Igbo", "yor": "Yoruba", "hau": "Hausa", "pcm": "Nigerian Pidgin"}
SYS = """You rewrite text for a Nigerian text-to-speech system as natural code-switched speech.
Follow the user's code-switch request exactly (which languages, how much of each, which parts).
Rules:
- Keep the meaning. Do not add new information, greetings or sentences that the request does not ask for.
- Write Igbo, Yoruba and Hausa in standard orthography (use tone marks/diacritics for Yoruba and Igbo if you can).
- Wrap every span in a language tag: <eng>...</eng>, <ibo>...</ibo>, <yor>...</yor>, <hau>...</hau>, <pcm>...</pcm>.
- Nothing may appear outside tags. Output ONLY the tagged text on one line, no explanations.
- Prefer these common, correct phrases when greetings/thanks/closings are requested:
Igbo: Ndewo (hello), Ụtụtụ ọma (good morning), Ehihie ọma (good afternoon), Mgbede ọma (good evening), Kedu? (how are you), Daalụ (thank you), Nnọọ (welcome), Ka ọ dị (goodbye)
Yoruba: Ẹ káàárọ̀ (good morning), Ẹ káàsán (good afternoon), Ẹ kú ìrọ̀lẹ́ (good evening), Ẹ kú àbọ̀ (welcome), Ẹ ṣé (thank you), Ó dàbọ̀ (goodbye)
Hausa: Sannu (hello), Barka da safiya (good morning), Barka da yamma (good evening), Maraba (welcome), Na gode (thank you), Sai an jima (goodbye)
Nigerian Pidgin: How far (hello), Abeg (please), Thank you well well, We go see later (goodbye)
- If you cannot translate a part reliably, keep that part in English rather than guessing."""
SHOT_U = "Request: mostly English, Igbo greetings and closing\nText: Good morning everyone. The meeting starts at ten, please be on time. Thank you."
SHOT_A = "<ibo>Ụtụtụ ọma, unu niile.</ibo> <eng>The meeting starts at ten, please be on time.</eng> <ibo>Daalụ.</ibo>"
TAG = re.compile(r"<(eng|ibo|yor|hau|pcm)>(.*?)</\1>", re.S)
def requested_langs(req):
r = req.lower()
out = {k for k, v in LANGS.items() if v.lower().split()[-1] in r or k in r}
if "pidgin" in r:
out.add("pcm")
return out or {"eng"}
def validate(out, src, allowed):
out = out.strip()
spans = TAG.findall(out)
if not spans:
return False, "no tags"
rest = TAG.sub(" ", out).strip()
if re.sub(r"[\s.,!?;:'\"-]", "", rest):
return False, f"text outside tags: {rest[:40]}"
bad = {l for l, _ in spans} - allowed - {"eng"}
if bad:
return False, f"unrequested language {bad}"
words = " ".join(t for _, t in spans).split()
n_src = len(src.split())
if not (0.4 * n_src <= len(words) <= 2.5 * n_src + 6):
return False, f"length ratio {len(words)}/{n_src}"
low = [w.lower().strip(".,!?") for w in words]
for k in range(len(low) - 3):
if low[k] == low[k + 1] == low[k + 2] == low[k + 3]:
return False, "word loop"
tri = Counter(tuple(low[i:i + 3]) for i in range(len(low) - 2))
if tri and tri.most_common(1)[0][1] > 2:
return False, "trigram loop"
if any(not t.strip() for _, t in spans):
return False, "empty span"
return True, "ok"
def chrf(hyp, ref, n=6, beta=2.0):
def grams(s, k):
s = " ".join(s.lower().split())
return Counter(s[i:i + k] for i in range(len(s) - k + 1))
P, R = [], []
for k in range(1, n + 1):
h, r = grams(hyp, k), grams(ref, k)
if not h or not r:
continue
m = sum((h & r).values())
P.append(m / sum(h.values())); R.append(m / sum(r.values()))
if not P:
return 0.0
p, r = sum(P) / len(P), sum(R) / len(R)
return 0.0 if p + r == 0 else 100 * (1 + beta ** 2) * p * r / (beta ** 2 * p + r)
class CodeSwitchFrontEnd:
def __init__(self, model=os.environ.get("CS_LLM", "Qwen/Qwen3-14B"), device="cuda", cache_dir=None, diac=None):
import torch
from transformers import AutoTokenizer, AutoModelForCausalLM
self.torch = torch
self.tok = AutoTokenizer.from_pretrained(model, cache_dir=cache_dir)
self.lm = AutoModelForCausalLM.from_pretrained(model, torch_dtype=torch.bfloat16, cache_dir=cache_dir).to(device).eval()
self.device = device
self.diac = diac # optional flash FrontEnd (ByT5 diacritic restoration) for ibo/yor spans
def _gen(self, req, text, temp):
msgs = [{"role": "system", "content": SYS}, {"role": "user", "content": SHOT_U}, {"role": "assistant", "content": SHOT_A},
{"role": "user", "content": f"Request: {req}\nText: {text}"}]
try:
p = self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
except TypeError:
p = self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
ids = self.tok(p, return_tensors="pt").to(self.device)
n_in = len(self.tok(text)["input_ids"])
# repetition rails act on the GENERATED tokens only (a prompt-wide no_repeat_ngram/repetition_penalty
# forbids copying the input and garbles spelling): hard length cap + loop stop + validation below
from transformers import StoppingCriteria, StoppingCriteriaList
P0 = ids["input_ids"].shape[1]
class LoopStop(StoppingCriteria):
def __call__(s_, input_ids, scores, **k):
g = input_ids[0, P0:].tolist()
if len(g) < 18:
return False
tail = tuple(g[-6:])
hits = sum(1 for i in range(len(g) - 6) if tuple(g[i:i + 6]) == tail)
return hits >= 2
kw = dict(max_new_tokens=int(3 * n_in + 48), pad_token_id=self.tok.eos_token_id,
stopping_criteria=StoppingCriteriaList([LoopStop()]))
if temp > 0:
kw.update(do_sample=True, temperature=temp, top_p=0.9)
else:
kw.update(do_sample=False, temperature=None, top_p=None, top_k=None)
with self.torch.no_grad():
o = self.lm.generate(**ids, **kw)
s = self.tok.decode(o[0, ids["input_ids"].shape[1]:], skip_special_tokens=True)
s = re.sub(r"<think>.*?</think>", "", s, flags=re.S).strip().splitlines()
return s[0].strip() if s else ""
def back_translate(self, span, lang):
msgs = [{"role": "user", "content": f"Translate this {LANGS[lang]} text into English. Output only the translation.\n\n{span}"}]
try:
p = self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True, enable_thinking=False)
except TypeError:
p = self.tok.apply_chat_template(msgs, tokenize=False, add_generation_prompt=True)
ids = self.tok(p, return_tensors="pt").to(self.device)
with self.torch.no_grad():
o = self.lm.generate(**ids, max_new_tokens=3 * len(span.split()) + 24, do_sample=False, temperature=None,
top_p=None, top_k=None, pad_token_id=self.tok.eos_token_id)
return self.tok.decode(o[0, ids["input_ids"].shape[1]:], skip_special_tokens=True).strip()
def meaning_check(self, out, src, thr=45.0):
"""round trip: back-translate every non-English span; the English reconstruction must match the input (chrF)."""
parts = [t if l == "eng" else self.back_translate(t, l) for l, t in TAG.findall(out)]
back = " ".join(parts)
score = min(chrf(back, src), chrf(back, src, beta=0.5)) # recall- and precision-weighted (added content fails)
return score >= thr, round(score, 1), back
def __call__(self, text, req, fallback_lang="eng"):
allowed = requested_langs(req)
log = []
for k, temp in enumerate((0.0, 0.6, 0.8)): # first try + at most 2 retries
out = self._gen(req, text, temp)
ok, why = validate(out, text, allowed)
rt = None
if ok:
ok, sc, back = self.meaning_check(out, text)
rt = dict(chrf=sc, back=back[:300])
if not ok:
why = f"meaning drift (round-trip chrF {sc})"
log.append(dict(try_=k, ok=ok, why=why, out=out[:300], roundtrip=rt))
if ok:
if self.diac is not None:
out = TAG.sub(lambda m: f"<{m.group(1)}>{self.diac.diacritise(m.group(2), m.group(1)) if m.group(1) in ('ibo', 'yor') else m.group(2)}</{m.group(1)}>", out)
return out, dict(ok=True, tries=log)
return f"<{fallback_lang}>{text}</{fallback_lang}>", dict(ok=False, tries=log, warning="code-switch front end failed validation; spoke the input as-is")
if __name__ == "__main__":
import sys
cache = os.environ.get("HF_CACHE")
fe = CodeSwitchFrontEnd(cache_dir=cache)
tests = json.load(open(sys.argv[1])) if len(sys.argv) > 1 else [
["Good morning everyone, welcome to the market. Prices are good today, come and buy.", "mostly English, Igbo greetings"],
["Please call your mother tonight, she has been waiting to hear from you.", "Igbo sentence with a few English words"],
["Welcome to our shop. Everything is half price this weekend, so tell your friends.", "Yoruba greeting, the rest in English"],
["Good evening. The rain will be heavy tomorrow, so carry an umbrella.", "half Hausa, half English"],
["My brother, the work is too much today, but we will finish it.", "Nigerian Pidgin mixed with Igbo"],
["Thank you for coming. We will start the program in five minutes.", "English with a Yoruba closing"],
]
res = []
for t, r in tests:
o, info = fe(t, r)
res.append(dict(text=t, request=r, out=o, ok=info["ok"], tries=len(info["tries"]), log=info["tries"]))
print(json.dumps(res[-1], ensure_ascii=False), flush=True)
json.dump(res, open(os.environ.get("CS_OUT", "cs_test.json"), "w"), ensure_ascii=False, indent=1)
|