| """Phoneme-level corruption for the Glossolalia Dial. |
| |
| Given a sentence + a dial level (0..4), returns the same sentence's phoneme sequence with |
| every phoneme drawn from a Boltzmann distribution over the 39 ARPAbet phonemes: |
| |
| q(y | x, level) β exp( -D_panphon(x, y) / T(level) ) * bias_weight(y) |
| |
| where D_panphon is the precomputed feature-edit-distance matrix from data/phoneme_lm.npz |
| (PanPhon library, Mortensen et al. COLING 2016 β values verified empirically: P/B=1, S/SH=2, |
| P/M=3, P/ZH=7, K/N=8, AA/P=11). T(level) is a temperature schedule: |
| |
| T(level) = 0.5 * exp(2.5 * p_level) |
| -> T(0)=0.50 (only Hamming<=1 neighbors get weight; near-identity) |
| -> T(2)=1.75 (distance-3 neighbors come into play) |
| -> T(4)=6.09 (full range opens; bias_weight steers the attractor) |
| |
| The temperature schedule is a design choice β exponential ramp so early dial departures |
| move only to near-identical phonemes (P->B, S->SH) and the dial only fully opens at the top. |
| No published precedent for this exact schedule; chosen by feel. |
| |
| bias_weight is the per-phoneme importance multiplier from the active preset (`dreamy`, |
| `sigur-ros`, `fraser`). The composition is multiplicative reweighting, not a formal |
| product-of-experts (which would require both terms to be exp(-energy)). Hand-tuned values. |
| |
| Stress markers and syllable count are preserved by 1-for-1 substitution. At levels 3-4 we |
| additionally apply CV cluster simplification: consonant-consonant onset runs collapse to a |
| single consonant. This is grounded in the documented 95.7% CV-structure preference in |
| real glossolalia (Link & Tomaschek 2024 PMC10916350; Samarin 1973 Language and Speech). |
| |
| Outputs four views of the corrupted phonemes: |
| - ARPAbet (with stress digits) β for training labels |
| - IPA (no stress) β for F5-TTS phoneme input (if model accepts it) |
| - pseudo (lowercase English orthography) β the in-distribution TTS input we feed F5-TTS |
| - display (UPPER-stressed, hyphen-syllab) β for the Gradio UI readout |
| |
| p_level: { 0: 0.0, 1: 0.25, 2: 0.50, 3: 0.75, 4: 1.0 } |
| """ |
|
|
| import argparse |
| import math |
| import sys |
| from pathlib import Path |
|
|
| import numpy as np |
|
|
| LEVEL_P = [0.0, 0.25, 0.50, 0.75, 1.0] |
|
|
|
|
| def temperature(level: int) -> float: |
| """T(level) = 0.5 * exp(2.5 * p_level). Design choice. See module docstring.""" |
| return 0.5 * math.exp(2.5 * LEVEL_P[level]) |
|
|
| VOWELS = {"AA","AE","AH","AO","AW","AY","EH","ER","EY","IH","IY","OW","OY","UH","UW"} |
|
|
| ARPABET_TO_IPA = { |
| "AA":"Ι","AE":"Γ¦","AH":"Κ","AO":"Ι","AW":"aΚ","AY":"aΙͺ","EH":"Ι","ER":"ΙΙΉ","EY":"eΙͺ", |
| "IH":"Ιͺ","IY":"i","OW":"oΚ","OY":"ΙΙͺ","UH":"Κ","UW":"u", |
| "B":"b","CH":"tΚ","D":"d","DH":"Γ°","F":"f","G":"Ι‘","HH":"h","JH":"dΚ","K":"k","L":"l", |
| "M":"m","N":"n","NG":"Ε","P":"p","R":"ΙΉ","S":"s","SH":"Κ","T":"t","TH":"ΞΈ","V":"v", |
| "W":"w","Y":"j","Z":"z","ZH":"Κ", |
| } |
|
|
| ARPABET_TO_SPELLING = { |
| "AA":"ah","AE":"a","AH":"uh","AO":"aw","AW":"ow","AY":"i","EH":"e","ER":"er","EY":"ay", |
| "IH":"i","IY":"ee","OW":"oh","OY":"oi","UH":"oo","UW":"oo", |
| "B":"b","CH":"ch","D":"d","DH":"th","F":"f","G":"g","HH":"h","JH":"j","K":"k","L":"l", |
| "M":"m","N":"n","NG":"ng","P":"p","R":"r","S":"s","SH":"sh","T":"t","TH":"th","V":"v", |
| "W":"w","Y":"y","Z":"z","ZH":"zh", |
| } |
|
|
|
|
| def load_lm(path): |
| d = np.load(path, allow_pickle=True) |
| out = { |
| "phonemes": list(d["phonemes"]), |
| "vowel_mask": d["vowel_mask"], |
| "unigram": d["unigram"], |
| "bigram": d["bigram"], |
| } |
| |
| |
| if "dist_matrix" in d.files: |
| out["dist_matrix"] = d["dist_matrix"] |
| if "bias_weights" in d.files: |
| out["bias_weights"] = d["bias_weights"] |
| return out |
|
|
|
|
| _G2P = None |
|
|
|
|
| def g2p_tokens(sentence: str): |
| """Returns the raw g2p_en token stream (interleaved phonemes + spaces/punctuation).""" |
| global _G2P |
| if _G2P is None: |
| |
| |
| import nltk |
| for res in ("averaged_perceptron_tagger_eng", "averaged_perceptron_tagger", "cmudict"): |
| try: |
| nltk.download(res, quiet=True) |
| except Exception: |
| pass |
| from g2p_en import G2p |
| _G2P = G2p() |
| return [t for t in _G2P(sentence) if t != ""] |
|
|
|
|
| def corrupt(tokens, level: int, lm, rng): |
| """Boltzmann substitution kernel + CV cluster simplification at high levels. |
| |
| Each ARPAbet phoneme x is replaced by a draw y ~ q(y|x, level) where |
| q(y|x, level) β exp(-D[x,y] / T(level)) * bias_weight(y) |
| using D = panphon feature-edit-distance matrix (raw count, 0-48) and T(level) = |
| 0.5 * exp(2.5 * p_level). At level=0, T=0.5 -> only distance-0 (self) gets meaningful |
| weight, so the lyric stays nearly intact. At level=4, T=6.09 -> the distribution spreads |
| and bias_weight steers toward the dreamy attractor. |
| |
| The legacy bigram path (old LM without dist_matrix) is preserved for backward compat. |
| """ |
| phonemes = lm["phonemes"] |
| idx = {ph: i for i, ph in enumerate(phonemes)} |
|
|
| use_boltzmann = "dist_matrix" in lm and "bias_weights" in lm |
| if use_boltzmann: |
| D = lm["dist_matrix"] |
| bw = lm["bias_weights"] |
| T = temperature(level) |
| |
| |
| logits = -D / T + np.log(np.clip(bw, 1e-12, None))[None, :] |
| logits = logits - logits.max(axis=1, keepdims=True) |
| Q = np.exp(logits) |
| Q = Q / Q.sum(axis=1, keepdims=True) |
| else: |
| |
| vmask = lm["vowel_mask"] |
| uni = lm["unigram"] |
| bi = lm["bigram"] |
| p_legacy = LEVEL_P[level] |
|
|
| out = [] |
| prev_i = None |
| for tok in tokens: |
| base = tok.rstrip("012") |
| stress = tok[len(base):] |
| if base not in idx: |
| out.append(tok) |
| continue |
| i = idx[base] |
| if use_boltzmann: |
| |
| new_i = int(rng.choice(len(phonemes), p=Q[i])) |
| new_base = phonemes[new_i] |
| else: |
| if rng.random() < p_legacy: |
| class_mask = vmask if base in VOWELS else (~vmask) |
| dist = bi[prev_i] if prev_i is not None else uni |
| d = dist * class_mask |
| if d.sum() == 0: |
| d = uni * class_mask |
| d = d / d.sum() |
| new_i = int(rng.choice(len(phonemes), p=d)) |
| new_base = phonemes[new_i] |
| else: |
| new_i = i |
| new_base = base |
| out.append(new_base + stress) |
| prev_i = new_i |
| if use_boltzmann and level >= 3: |
| out = _simplify_clusters(out) |
| return out |
|
|
|
|
| def _simplify_clusters(tokens): |
| """Collapse CC onset runs to single onset at levels 3-4. |
| |
| A CC onset run is two consecutive ARPAbet consonants between a word break and a vowel. |
| We drop the second consonant. CV preference is documented in real glossolalia |
| (Link & Tomaschek 2024 PMC10916350 β 95.7% CV; Samarin 1973 β open-syllable preference). |
| """ |
| out = [] |
| i = 0 |
| n = len(tokens) |
| while i < n: |
| tok = tokens[i] |
| base = tok.rstrip("012") |
| |
| |
| prev_is_break = (len(out) == 0) or (not out[-1].rstrip("012").isalpha()) or \ |
| (out[-1].rstrip("012") not in (set(VOWELS) | _CONSONANTS)) |
| if prev_is_break and base in _CONSONANTS and i + 1 < n: |
| nxt = tokens[i + 1].rstrip("012") |
| if nxt in _CONSONANTS and i + 2 < n: |
| nxt2 = tokens[i + 2].rstrip("012") |
| if nxt2 in VOWELS: |
| |
| out.append(tok) |
| out.append(tokens[i + 2]) |
| i += 3 |
| continue |
| out.append(tok) |
| i += 1 |
| return out |
|
|
|
|
| _CONSONANTS = {"B","CH","D","DH","F","G","HH","JH","K","L","M","N","NG","P","R","S","SH", |
| "T","TH","V","W","Y","Z","ZH"} |
|
|
|
|
| def to_ipa(tokens): |
| parts = [] |
| for tok in tokens: |
| base = tok.rstrip("012") |
| parts.append(ARPABET_TO_IPA.get(base, tok)) |
| return "".join(parts) |
|
|
|
|
| def to_spelling(tokens): |
| """Lowercase pseudo-English orthography. THE input we feed to F5-TTS at training and |
| inference time β empirically in-distribution per F5-TTS issue #362 (owner SWivid confirms |
| 'current base models are using characters rather than phonemes').""" |
| parts = [] |
| for tok in tokens: |
| base = tok.rstrip("012") |
| parts.append(ARPABET_TO_SPELLING.get(base, tok if not base.isalpha() else "")) |
| return "".join(parts).strip() |
|
|
|
|
| def to_display(tokens): |
| """UI-readable rendering of the corrupted lyric. |
| |
| Uppercase the glyph for any stressed (digit=1) phoneme, lowercase otherwise. Insert a |
| hyphen between consecutive phoneme glyphs within a word. Word breaks (spaces and |
| punctuation from g2p) pass through unchanged. |
| |
| Example: 'i KWIK-lee kuh-LEK-tuhd' for tokens with stress on KWIK and LEK. |
| |
| ASCII-only β Merriam-Webster diacritics break F5-TTS's character tokenizer, so we keep |
| this format compatible with the TTS input pipeline (the `pseudo` string remains the |
| actual TTS input; `display` is for the Gradio readout only). |
| """ |
| parts = [] |
| prev_was_phoneme = False |
| for tok in tokens: |
| base = tok.rstrip("012") |
| stress = tok[len(base):] |
| glyph = ARPABET_TO_SPELLING.get(base) |
| if glyph is None: |
| |
| parts.append(tok if not base.isalpha() else "") |
| prev_was_phoneme = False |
| continue |
| if stress.startswith("1"): |
| glyph = glyph.upper() |
| if prev_was_phoneme: |
| parts.append("-") |
| parts.append(glyph) |
| prev_was_phoneme = True |
| return "".join(parts).strip() |
|
|
|
|
| def corrupt_sentence(sentence: str, level: int, lm, seed: int = 0): |
| """Returns (arpabet_tokens, ipa, pseudo_spelling, display). |
| |
| pseudo_spelling is the lowercase TTS input. display is the UI readout. |
| """ |
| rng = np.random.default_rng(seed) |
| tokens = g2p_tokens(sentence) |
| corrupted = corrupt(tokens, level, lm, rng) |
| return corrupted, to_ipa(corrupted), to_spelling(corrupted), to_display(corrupted) |
|
|
|
|
| def main(): |
| p = argparse.ArgumentParser() |
| p.add_argument("--sentence", required=True) |
| p.add_argument("--level", type=int, required=True, choices=[0, 1, 2, 3, 4]) |
| p.add_argument("--lm", default="data/phoneme_lm.npz") |
| p.add_argument("--seed", type=int, default=0) |
| args = p.parse_args() |
|
|
| lm = load_lm(Path(args.lm)) |
| arpa_orig = g2p_tokens(args.sentence) |
| corrupted, ipa, pseudo, display = corrupt_sentence(args.sentence, args.level, lm, args.seed) |
|
|
| print(f"original ARPABET : {' '.join(t for t in arpa_orig if t.strip())}") |
| print(f"level {args.level} (p={LEVEL_P[args.level]:.2f}, T={temperature(args.level):.3f})") |
| print(f" ARPABET : {' '.join(t for t in corrupted if t.strip())}") |
| print(f" IPA : {ipa}") |
| print(f" PSEUDO : {pseudo}") |
| print(f" DISPLAY : {display}") |
|
|
|
|
| if __name__ == "__main__": |
| main() |
|
|