akshan-main commited on
Commit
11aa1d8
Β·
verified Β·
1 Parent(s): f5ff958

mirror scripts/corrupt_phonemes.py

Browse files
Files changed (1) hide show
  1. scripts/corrupt_phonemes.py +295 -0
scripts/corrupt_phonemes.py ADDED
@@ -0,0 +1,295 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Phoneme-level corruption for the Glossolalia Dial.
2
+
3
+ Given a sentence + a dial level (0..4), returns the same sentence's phoneme sequence with
4
+ every phoneme drawn from a Boltzmann distribution over the 39 ARPAbet phonemes:
5
+
6
+ q(y | x, level) ∝ exp( -D_panphon(x, y) / T(level) ) * bias_weight(y)
7
+
8
+ where D_panphon is the precomputed feature-edit-distance matrix from data/phoneme_lm.npz
9
+ (PanPhon library, Mortensen et al. COLING 2016 β€” values verified empirically: P/B=1, S/SH=2,
10
+ P/M=3, P/ZH=7, K/N=8, AA/P=11). T(level) is a temperature schedule:
11
+
12
+ T(level) = 0.5 * exp(2.5 * p_level)
13
+ -> T(0)=0.50 (only Hamming<=1 neighbors get weight; near-identity)
14
+ -> T(2)=1.75 (distance-3 neighbors come into play)
15
+ -> T(4)=6.09 (full range opens; bias_weight steers the attractor)
16
+
17
+ The temperature schedule is a design choice β€” exponential ramp so early dial departures
18
+ move only to near-identical phonemes (P->B, S->SH) and the dial only fully opens at the top.
19
+ No published precedent for this exact schedule; chosen by feel.
20
+
21
+ bias_weight is the per-phoneme importance multiplier from the active preset (`dreamy`,
22
+ `sigur-ros`, `fraser`). The composition is multiplicative reweighting, not a formal
23
+ product-of-experts (which would require both terms to be exp(-energy)). Hand-tuned values.
24
+
25
+ Stress markers and syllable count are preserved by 1-for-1 substitution. At levels 3-4 we
26
+ additionally apply CV cluster simplification: consonant-consonant onset runs collapse to a
27
+ single consonant. This is grounded in the documented 95.7% CV-structure preference in
28
+ real glossolalia (Link & Tomaschek 2024 PMC10916350; Samarin 1973 Language and Speech).
29
+
30
+ Outputs four views of the corrupted phonemes:
31
+ - ARPAbet (with stress digits) β€” for training labels
32
+ - IPA (no stress) β€” for F5-TTS phoneme input (if model accepts it)
33
+ - pseudo (lowercase English orthography) β€” the in-distribution TTS input we feed F5-TTS
34
+ - display (UPPER-stressed, hyphen-syllab) β€” for the Gradio UI readout
35
+
36
+ p_level: { 0: 0.0, 1: 0.25, 2: 0.50, 3: 0.75, 4: 1.0 }
37
+ """
38
+
39
+ import argparse
40
+ import math
41
+ import sys
42
+ from pathlib import Path
43
+
44
+ import numpy as np
45
+
46
+ LEVEL_P = [0.0, 0.25, 0.50, 0.75, 1.0]
47
+
48
+
49
+ def temperature(level: int) -> float:
50
+ """T(level) = 0.5 * exp(2.5 * p_level). Design choice. See module docstring."""
51
+ return 0.5 * math.exp(2.5 * LEVEL_P[level])
52
+
53
+ VOWELS = {"AA","AE","AH","AO","AW","AY","EH","ER","EY","IH","IY","OW","OY","UH","UW"}
54
+
55
+ ARPABET_TO_IPA = {
56
+ "AA":"Ι‘","AE":"Γ¦","AH":"ʌ","AO":"Ι”","AW":"aʊ","AY":"aΙͺ","EH":"Ι›","ER":"ɜɹ","EY":"eΙͺ",
57
+ "IH":"Ιͺ","IY":"i","OW":"oʊ","OY":"Ι”Ιͺ","UH":"ʊ","UW":"u",
58
+ "B":"b","CH":"tʃ","D":"d","DH":"ð","F":"f","G":"ɑ","HH":"h","JH":"dʒ","K":"k","L":"l",
59
+ "M":"m","N":"n","NG":"Ε‹","P":"p","R":"ΙΉ","S":"s","SH":"Κƒ","T":"t","TH":"ΞΈ","V":"v",
60
+ "W":"w","Y":"j","Z":"z","ZH":"Κ’",
61
+ }
62
+
63
+ ARPABET_TO_SPELLING = {
64
+ "AA":"ah","AE":"a","AH":"uh","AO":"aw","AW":"ow","AY":"i","EH":"e","ER":"er","EY":"ay",
65
+ "IH":"i","IY":"ee","OW":"oh","OY":"oi","UH":"oo","UW":"oo",
66
+ "B":"b","CH":"ch","D":"d","DH":"th","F":"f","G":"g","HH":"h","JH":"j","K":"k","L":"l",
67
+ "M":"m","N":"n","NG":"ng","P":"p","R":"r","S":"s","SH":"sh","T":"t","TH":"th","V":"v",
68
+ "W":"w","Y":"y","Z":"z","ZH":"zh",
69
+ }
70
+
71
+
72
+ def load_lm(path):
73
+ d = np.load(path, allow_pickle=True)
74
+ out = {
75
+ "phonemes": list(d["phonemes"]),
76
+ "vowel_mask": d["vowel_mask"],
77
+ "unigram": d["unigram"],
78
+ "bigram": d["bigram"],
79
+ }
80
+ # v6 keys added by build_phoneme_lm.py: PanPhon distance matrix + per-phoneme bias weights.
81
+ # Old LMs without these fall back to bigram-conditional sampling (legacy code path).
82
+ if "dist_matrix" in d.files:
83
+ out["dist_matrix"] = d["dist_matrix"]
84
+ if "bias_weights" in d.files:
85
+ out["bias_weights"] = d["bias_weights"]
86
+ return out
87
+
88
+
89
+ _G2P = None
90
+
91
+
92
+ def g2p_tokens(sentence: str):
93
+ """Returns the raw g2p_en token stream (interleaved phonemes + spaces/punctuation)."""
94
+ global _G2P
95
+ if _G2P is None:
96
+ # g2p_en uses NLTK's pos_tag which (since NLTK 3.9) wants the *_eng suffixed taggers,
97
+ # but g2p_en's own bootstrap still references the legacy names. Pre-fetch both quietly.
98
+ import nltk
99
+ for res in ("averaged_perceptron_tagger_eng", "averaged_perceptron_tagger", "cmudict"):
100
+ try:
101
+ nltk.download(res, quiet=True)
102
+ except Exception:
103
+ pass
104
+ from g2p_en import G2p
105
+ _G2P = G2p()
106
+ return [t for t in _G2P(sentence) if t != ""]
107
+
108
+
109
+ def corrupt(tokens, level: int, lm, rng):
110
+ """Boltzmann substitution kernel + CV cluster simplification at high levels.
111
+
112
+ Each ARPAbet phoneme x is replaced by a draw y ~ q(y|x, level) where
113
+ q(y|x, level) ∝ exp(-D[x,y] / T(level)) * bias_weight(y)
114
+ using D = panphon feature-edit-distance matrix (raw count, 0-48) and T(level) =
115
+ 0.5 * exp(2.5 * p_level). At level=0, T=0.5 -> only distance-0 (self) gets meaningful
116
+ weight, so the lyric stays nearly intact. At level=4, T=6.09 -> the distribution spreads
117
+ and bias_weight steers toward the dreamy attractor.
118
+
119
+ The legacy bigram path (old LM without dist_matrix) is preserved for backward compat.
120
+ """
121
+ phonemes = lm["phonemes"]
122
+ idx = {ph: i for i, ph in enumerate(phonemes)}
123
+
124
+ use_boltzmann = "dist_matrix" in lm and "bias_weights" in lm
125
+ if use_boltzmann:
126
+ D = lm["dist_matrix"]
127
+ bw = lm["bias_weights"]
128
+ T = temperature(level)
129
+ # Precompute per-source distributions so we don't redo softmax per token.
130
+ # logits[i, j] = -D[i,j]/T + log(bw[j])
131
+ logits = -D / T + np.log(np.clip(bw, 1e-12, None))[None, :]
132
+ logits = logits - logits.max(axis=1, keepdims=True)
133
+ Q = np.exp(logits)
134
+ Q = Q / Q.sum(axis=1, keepdims=True)
135
+ else:
136
+ # Legacy: per-class bigram fallback (kept for old LMs)
137
+ vmask = lm["vowel_mask"]
138
+ uni = lm["unigram"]
139
+ bi = lm["bigram"]
140
+ p_legacy = LEVEL_P[level]
141
+
142
+ out = []
143
+ prev_i = None
144
+ for tok in tokens:
145
+ base = tok.rstrip("012")
146
+ stress = tok[len(base):]
147
+ if base not in idx:
148
+ out.append(tok)
149
+ continue
150
+ i = idx[base]
151
+ if use_boltzmann:
152
+ # Boltzmann draw at this level. At level=0 this is almost always self.
153
+ new_i = int(rng.choice(len(phonemes), p=Q[i]))
154
+ new_base = phonemes[new_i]
155
+ else:
156
+ if rng.random() < p_legacy:
157
+ class_mask = vmask if base in VOWELS else (~vmask)
158
+ dist = bi[prev_i] if prev_i is not None else uni
159
+ d = dist * class_mask
160
+ if d.sum() == 0:
161
+ d = uni * class_mask
162
+ d = d / d.sum()
163
+ new_i = int(rng.choice(len(phonemes), p=d))
164
+ new_base = phonemes[new_i]
165
+ else:
166
+ new_i = i
167
+ new_base = base
168
+ out.append(new_base + stress)
169
+ prev_i = new_i
170
+ if use_boltzmann and level >= 3:
171
+ out = _simplify_clusters(out)
172
+ return out
173
+
174
+
175
+ def _simplify_clusters(tokens):
176
+ """Collapse CC onset runs to single onset at levels 3-4.
177
+
178
+ A CC onset run is two consecutive ARPAbet consonants between a word break and a vowel.
179
+ We drop the second consonant. CV preference is documented in real glossolalia
180
+ (Link & Tomaschek 2024 PMC10916350 β€” 95.7% CV; Samarin 1973 β€” open-syllable preference).
181
+ """
182
+ out = []
183
+ i = 0
184
+ n = len(tokens)
185
+ while i < n:
186
+ tok = tokens[i]
187
+ base = tok.rstrip("012")
188
+ # Detect: previous emitted is a non-phoneme (word break) AND current+next are both
189
+ # consonants AND the one AFTER next is a vowel β€” collapse to single onset.
190
+ prev_is_break = (len(out) == 0) or (not out[-1].rstrip("012").isalpha()) or \
191
+ (out[-1].rstrip("012") not in (set(VOWELS) | _CONSONANTS))
192
+ if prev_is_break and base in _CONSONANTS and i + 1 < n:
193
+ nxt = tokens[i + 1].rstrip("012")
194
+ if nxt in _CONSONANTS and i + 2 < n:
195
+ nxt2 = tokens[i + 2].rstrip("012")
196
+ if nxt2 in VOWELS:
197
+ # Drop tokens[i+1] β€” keep the first onset only.
198
+ out.append(tok)
199
+ out.append(tokens[i + 2])
200
+ i += 3
201
+ continue
202
+ out.append(tok)
203
+ i += 1
204
+ return out
205
+
206
+
207
+ _CONSONANTS = {"B","CH","D","DH","F","G","HH","JH","K","L","M","N","NG","P","R","S","SH",
208
+ "T","TH","V","W","Y","Z","ZH"}
209
+
210
+
211
+ def to_ipa(tokens):
212
+ parts = []
213
+ for tok in tokens:
214
+ base = tok.rstrip("012")
215
+ parts.append(ARPABET_TO_IPA.get(base, tok))
216
+ return "".join(parts)
217
+
218
+
219
+ def to_spelling(tokens):
220
+ """Lowercase pseudo-English orthography. THE input we feed to F5-TTS at training and
221
+ inference time β€” empirically in-distribution per F5-TTS issue #362 (owner SWivid confirms
222
+ 'current base models are using characters rather than phonemes')."""
223
+ parts = []
224
+ for tok in tokens:
225
+ base = tok.rstrip("012")
226
+ parts.append(ARPABET_TO_SPELLING.get(base, tok if not base.isalpha() else ""))
227
+ return "".join(parts).strip()
228
+
229
+
230
+ def to_display(tokens):
231
+ """UI-readable rendering of the corrupted lyric.
232
+
233
+ Uppercase the glyph for any stressed (digit=1) phoneme, lowercase otherwise. Insert a
234
+ hyphen between consecutive phoneme glyphs within a word. Word breaks (spaces and
235
+ punctuation from g2p) pass through unchanged.
236
+
237
+ Example: 'i KWIK-lee kuh-LEK-tuhd' for tokens with stress on KWIK and LEK.
238
+
239
+ ASCII-only β€” Merriam-Webster diacritics break F5-TTS's character tokenizer, so we keep
240
+ this format compatible with the TTS input pipeline (the `pseudo` string remains the
241
+ actual TTS input; `display` is for the Gradio readout only).
242
+ """
243
+ parts = []
244
+ prev_was_phoneme = False
245
+ for tok in tokens:
246
+ base = tok.rstrip("012")
247
+ stress = tok[len(base):]
248
+ glyph = ARPABET_TO_SPELLING.get(base)
249
+ if glyph is None:
250
+ # Word break / punctuation
251
+ parts.append(tok if not base.isalpha() else "")
252
+ prev_was_phoneme = False
253
+ continue
254
+ if stress.startswith("1"):
255
+ glyph = glyph.upper()
256
+ if prev_was_phoneme:
257
+ parts.append("-")
258
+ parts.append(glyph)
259
+ prev_was_phoneme = True
260
+ return "".join(parts).strip()
261
+
262
+
263
+ def corrupt_sentence(sentence: str, level: int, lm, seed: int = 0):
264
+ """Returns (arpabet_tokens, ipa, pseudo_spelling, display).
265
+
266
+ pseudo_spelling is the lowercase TTS input. display is the UI readout.
267
+ """
268
+ rng = np.random.default_rng(seed)
269
+ tokens = g2p_tokens(sentence)
270
+ corrupted = corrupt(tokens, level, lm, rng)
271
+ return corrupted, to_ipa(corrupted), to_spelling(corrupted), to_display(corrupted)
272
+
273
+
274
+ def main():
275
+ p = argparse.ArgumentParser()
276
+ p.add_argument("--sentence", required=True)
277
+ p.add_argument("--level", type=int, required=True, choices=[0, 1, 2, 3, 4])
278
+ p.add_argument("--lm", default="data/phoneme_lm.npz")
279
+ p.add_argument("--seed", type=int, default=0)
280
+ args = p.parse_args()
281
+
282
+ lm = load_lm(Path(args.lm))
283
+ arpa_orig = g2p_tokens(args.sentence)
284
+ corrupted, ipa, pseudo, display = corrupt_sentence(args.sentence, args.level, lm, args.seed)
285
+
286
+ print(f"original ARPABET : {' '.join(t for t in arpa_orig if t.strip())}")
287
+ print(f"level {args.level} (p={LEVEL_P[args.level]:.2f}, T={temperature(args.level):.3f})")
288
+ print(f" ARPABET : {' '.join(t for t in corrupted if t.strip())}")
289
+ print(f" IPA : {ipa}")
290
+ print(f" PSEUDO : {pseudo}")
291
+ print(f" DISPLAY : {display}")
292
+
293
+
294
+ if __name__ == "__main__":
295
+ main()