glossolalia / scripts /mondegreen.py
akshan-main's picture
zerogpu: eager model load fixes consecutive-call crash; ghost dial 3/4 distinct
e8b267d verified
Raw
History Blame Contribute Delete
25 kB
"""Mondegreen substitution for the Glossolalia Dial — "Ghost mode".
Given an input lyric + a dial level, returns a sequence of REAL English words whose phoneme
sequence is phonetically close to the source. The output is meant to be sung in place of
the original lyric and read by listeners as the source via pareidolia (Cocteau Twins-style):
"the river was wide and calm in the morning light" (source, lv0)
"the reefer was white and come in the mourning knight" (mondegreen, lv4)
Mechanism (fully deterministic):
1. g2p_en converts input -> ARPAbet phoneme sequence
2. For each word, look up its phonemes in CMUdict (canonical pronunciation)
3. Find candidate substitute words in CMUdict whose phoneme sequence is within
dial-conditioned PanPhon feature-edit-distance of the source word's phonemes
4. Sample deterministically from the candidate set, weighted by 1/(1+distance) so
near substitutes are preferred over far ones at any given dial level
5. Reassemble the substituted words into a lyric string
Coverage constraints:
- Match source syllable count exactly (preserves singability over the original melody)
- Substitution probability scales with dial level: p ∈ [0, 0.25, 0.5, 0.75, 1.0]
- Distance threshold scales with dial level: max_dist ∈ [0, 2, 4, 6, 8] (raw PanPhon
feature-edit-distance, scale 0-13 for typical phoneme pairs)
- If no candidate is found within the threshold, the source word passes through unchanged
(graceful degradation for OOV / rare phonotactic combinations like "courtyard")
What this is NOT:
- A generative model. No LLM, no neural net. Just a phoneme-distance search over CMUdict.
- A guarantee that the output is grammatical or semantically sensible. The output may read
as a nonsense sentence of real English words. That's the point — the listener's brain
fills in meaning via pareidolia.
Sources used (verified):
- PanPhon library, Mortensen et al. COLING 2016, aclanthology.org/C16-1328 (used as software)
- CMUdict pronunciation dictionary, ~135K English entries
Design choices (no published precedent claimed):
- Distance schedule and probability schedule are hand-chosen for a smooth dial.
- Syllable-count enforcement is a design choice for singability, grounded in the standard
lyric-substitution constraint in parody/pastiche traditions (no specific citation).
- Stress-position match is enforced (same primary-stress syllable index in source and
candidate). Music-cognition literature (Kolinsky et al., Empirical Musicology Review)
surfaced by workflow synthesis — not personally re-verified for this docstring.
- Function-word handling: HELD CONSTANT at all dial levels (small closed-class set in
_FUNCTION_WORDS). Substituting them adds noise where listeners process with delayed
commitment; we skip them so the ghost lands on the content-word stress points.
"""
from __future__ import annotations
import functools
import math
import re
import urllib.request
from pathlib import Path
from typing import Iterable
import numpy as np
from wordfreq import zipf_frequency
@functools.lru_cache(maxsize=200_000)
def _zipf(word: str) -> float:
"""Cached zipf frequency (log10 per-billion) of an English word; 0.0 if unknown."""
return zipf_frequency(word.replace("'", ""), "en")
# ---- Constants ----
LEVEL_P = [0.0, 0.25, 0.50, 0.75, 1.0] # per-word substitution probability
# PanPhon feature-edit-distance cap per level. The nearest real-word mishearing scales
# with word length: short words ("sells") have one at distance ~1, but multi-syllable
# words ("seashells", "seashore") have their nearest common-word mishearing at ~7-8.
# So the cap must reach far enough at high levels or long words pass through unchanged
# (the "dial 4 still says seashells" bug). Low levels stay tight = subtle, few changes;
# lv4 (p=1.0, full dissolution) reaches 10 so every content word actually transforms.
LEVEL_MAX_DIST = [0.0, 3.0, 5.0, 7.5, 10.0]
# Per-level preference on candidate phonetic distance, added to the LM coherence score
# during beam search. Negative = prefer the NEAREST (most natural) mishearing. Low dials
# pull toward the closest swap so the change reads as a near-miss ("sells -> sills"); the
# high dials sit at zero and let the nearest-skip below (LEVEL_SKIP_NEAR) do the work of
# reaching for farther, weirder mishearings, with the LM picking the most coherent one in
# that farther pool. Tuned against the LM score scale (mean per-token log-prob, ~-4 to -7).
LEVEL_DIST_PREF = [0.0, -0.18, -0.09, 0.0, 0.0]
# Fraction of each chosen word's NEAREST mishearings to drop from the beam's candidate pool,
# per level. Zero at low dials so they keep the most natural (closest) swap. Rising at the top
# so the same word is pushed past its obvious mishearing onto a farther, weirder one. This is
# the saturation fix: a short sentence ("she sells seashells by the seashore", three content
# words) has every word already changed by dial 3, so dial 4 has nothing new to change unless
# it changes the SAME words further. The soft LEVEL_DIST_PREF alone can't dislodge a strongly
# coherent near pick (the LM loves "seashells -> seagulls"); dropping the nearest candidates
# forces the beam off it. Capped per-word so a short candidate ladder still keeps a handful.
LEVEL_SKIP_NEAR = [0.0, 0.0, 0.0, 0.20, 0.55]
# Minimum word-frequency (zipf scale, log10 per-billion) for a candidate to qualify.
# CMUdict has ~135k entries including rare surnames / abbreviations ("selz" zipf 1.4).
# zipf >= 2.5 keeps real mishearings ("seashells" 2.5, "reefer" 2.7) and rejects junk.
MIN_CANDIDATE_ZIPF = 2.5
CMUDICT_URL = "https://raw.githubusercontent.com/cmusphinx/cmudict/master/cmudict.dict"
_ARPABET_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": "ʒ",
}
# Word-token splitter that preserves whitespace and punctuation.
_TOKEN_RE = re.compile(r"([A-Za-z']+|[^A-Za-z']+)")
# Function words held constant — they're typically unstressed and listeners skip over them
# perceptually; substituting them adds noise without much added ghost effect. The list is
# the standard short closed-class items in English (articles, prepositions, conjunctions,
# auxiliaries, common pronouns). Hand-curated; no published precedent claimed.
_FUNCTION_WORDS = frozenset({
"the", "a", "an", "and", "or", "but", "nor", "so", "yet", "for",
"of", "in", "on", "at", "to", "by", "with", "from", "into", "onto",
"is", "am", "are", "was", "were", "be", "been", "being",
"i", "me", "my", "you", "your", "he", "him", "his", "she", "her",
"it", "its", "we", "us", "our", "they", "them", "their",
"this", "that", "these", "those", "as", "if", "than",
})
def _syllable_count(phones: tuple[str, ...]) -> int:
return sum(1 for p in phones if p in _ARPABET_VOWELS)
def _phones_to_ipa_seq(phones: tuple[str, ...]) -> str:
return " ".join(_ARPABET_TO_IPA[p] for p in phones if p in _ARPABET_TO_IPA)
def _primary_stress_syllable(phones_with_stress: tuple[str, ...]) -> int:
"""Index (0-based) of the syllable carrying primary stress (digit '1').
Returns -1 if no primary stress is marked (e.g. single-syllable function words).
Used to match source-vs-candidate stress position: substituting a trochaic word
(stress on syllable 0) with an iambic candidate (stress on syllable 1) makes the
ghost rhythmically wrong and breaks perception under the original melody.
Citation provenance: Kolinsky et al. (emusicology.org/article/view/3729) + EEG
study PMC3225926 — stress mismatch degrades word recognition in song contexts.
"""
syl_idx = -1
for p in phones_with_stress:
base = p.rstrip("012")
if base in _ARPABET_VOWELS:
syl_idx += 1
if p.endswith("1"):
return syl_idx
return -1
class MondegreenIndex:
"""Per-process CMUdict index plus PanPhon distance computation.
Heavy to construct (~5 s on cold start: dict parse + IPA conversion + bucketing).
Cache the instance at the module/app level.
"""
def __init__(self, cmudict_path: str | Path = "data/cmudict.dict"):
import panphon.distance
self._dist = panphon.distance.Distance()
cmu = Path(cmudict_path)
if not cmu.exists():
cmu.parent.mkdir(parents=True, exist_ok=True)
cmu.write_text(urllib.request.urlopen(CMUDICT_URL, timeout=60)
.read().decode("utf-8", errors="ignore"))
word_to_arpa: dict[str, tuple[str, ...]] = {}
word_to_arpa_stressed: dict[str, tuple[str, ...]] = {}
for line in cmu.read_text(encoding="utf-8", errors="ignore").splitlines():
line = line.strip()
if not line or line.startswith(";;;"):
continue
parts = line.split()
if len(parts) < 2:
continue
word = parts[0].lower().split("(")[0]
phones_with_stress = tuple(parts[1:])
phones = tuple(tok.rstrip("012") for tok in phones_with_stress if tok)
# Reject lines that contain non-ARPAbet tokens
if any(p not in _ARPABET_TO_IPA for p in phones):
continue
# Keep canonical pronunciation only (first entry per word)
if word.replace("'", "").isalpha() and phones and word not in word_to_arpa:
word_to_arpa[word] = phones
word_to_arpa_stressed[word] = phones_with_stress
self._word_to_arpa = word_to_arpa
self._word_to_arpa_stressed = word_to_arpa_stressed
self._word_to_syl = {w: _syllable_count(p) for w, p in word_to_arpa.items()}
self._word_to_ipa = {w: _phones_to_ipa_seq(p) for w, p in word_to_arpa.items()}
self._word_to_stress_pos = {
w: _primary_stress_syllable(word_to_arpa_stressed[w])
for w in word_to_arpa
}
# Syllable bucket for fast candidate enumeration, sub-keyed by primary stress position
# so candidate scans are O(bucket size for matching stress) rather than O(all words).
self._syl_stress_bucket: dict[tuple[int, int], list[str]] = {}
for w in self._word_to_arpa:
key = (self._word_to_syl[w], self._word_to_stress_pos[w])
self._syl_stress_bucket.setdefault(key, []).append(w)
@property
def size(self) -> int:
return len(self._word_to_arpa)
def find_candidates(self, source_word: str, max_dist: float,
max_results: int = 64) -> list[tuple[str, float]]:
"""Return list of (candidate_word, distance) sorted by distance ascending.
Restricts candidates to the same syllable bucket so the substitution preserves
the original lyric's prosodic shape. Filters out 1-letter candidates (single
letters like "y", "u", "z" appear in CMUdict but read poorly in lyrics).
"""
word = source_word.lower()
if word not in self._word_to_arpa:
return []
src_ipa = self._word_to_ipa[word]
src_syl = self._word_to_syl[word]
src_stress = self._word_to_stress_pos[word]
# Candidates are bucketed by (syllable count, primary stress position) — both
# must match. Stress-mismatched substitutes break the perceptual ghost under
# the original melody (Kolinsky et al.; EEG study PMC3225926).
bucket = self._syl_stress_bucket.get((src_syl, src_stress), [])
cands: list[tuple[str, float]] = []
for cand in bucket:
if cand == word or len(cand.replace("'", "")) <= 1:
continue
# Real-common-word gate: reject rare CMUdict entries (surnames,
# abbreviations, archaisms) so the ghost reads as ordinary English.
if _zipf(cand) < MIN_CANDIDATE_ZIPF:
continue
d = float(self._dist.hamming_feature_edit_distance(
src_ipa, self._word_to_ipa[cand]) * 24.0)
if d <= max_dist:
cands.append((cand, d))
cands.sort(key=lambda x: x[1])
return cands[:max_results]
def substitute_word(self, word: str, level: int,
rng: np.random.Generator) -> str:
"""Pick a mondegreen for `word` at the given dial level.
Returns the source word unchanged if (a) it's a function word (closed-class
item we hold constant for prosodic stability), (b) the dial RNG draw doesn't
fire, or (c) no candidate exists within the level-conditioned distance.
"""
if level <= 0:
return word
if word.lower() in _FUNCTION_WORDS:
return word
p = LEVEL_P[level]
if rng.random() >= p:
return word
cands = self.find_candidates(word, max_dist=LEVEL_MAX_DIST[level])
if not cands:
return word
# Weighted by 1/(1+d): near substitutes preferred but far ones not impossible.
weights = np.array([1.0 / (1.0 + d) for _, d in cands], dtype=np.float64)
weights = weights / weights.sum()
idx = int(rng.choice(len(cands), p=weights))
return cands[idx][0]
def substitute(self, sentence: str, level: int, seed: int = 0,
reranker: "LMReranker | None" = None,
beam_width: int = 8,
n_candidates_per_word: int = 12) -> str:
"""Mondegreen-substitute every alphabetic word in `sentence` at `level`.
Whitespace and punctuation pass through unchanged. Words not in CMUdict
(proper names, neologisms) pass through unchanged.
If `reranker` is provided, runs constrained beam search: at each substitutable
word position the top `n_candidates_per_word` phonetic candidates are expanded
across all `beam_width` live beams; each partial sequence is scored by the
reranker's log-probability under a small causal language model; only the top
`beam_width` beams survive to the next position. Final output is the highest-
scoring complete sequence. Determinism preserved: argmax not sample, ties
broken by candidate distance then alphabetical.
Without a reranker, falls back to the per-word weighted draw (no semantic
coherence — substitutions are independent across positions).
"""
if reranker is None or level == 0:
return self._substitute_independent(sentence, level, seed)
return self._substitute_beam(sentence, level, seed, reranker,
beam_width, n_candidates_per_word)
def _substitute_independent(self, sentence: str, level: int, seed: int) -> str:
rng = np.random.default_rng(seed)
out_parts: list[str] = []
for tok in _TOKEN_RE.findall(sentence):
if tok.replace("'", "").isalpha():
out_parts.append(self.substitute_word(tok, level, rng))
else:
out_parts.append(tok)
return self._restore_caps(sentence, out_parts)
def _restore_caps(self, sentence: str, out_parts: list[str]) -> str:
rebuilt: list[str] = []
src_tokens = _TOKEN_RE.findall(sentence)
for src, out in zip(src_tokens, out_parts):
if src and src[0].isupper() and out and out[0].islower():
out = out[0].upper() + out[1:]
rebuilt.append(out)
return "".join(rebuilt)
def _substitute_beam(self, sentence: str, level: int, seed: int,
reranker: "LMReranker",
beam_width: int, n_candidates_per_word: int) -> str:
tokens = _TOKEN_RE.findall(sentence)
# COUNT-BASED, MONOTONIC selection (replaces probabilistic per-word firing, which
# left short sentences unchanged at low dials so levels 0/1/2 looked identical).
# The level controls HOW MANY content words change; each changed word takes its
# nearest real-word mishearing. Word priority = closest-mishearing first, so the
# most natural swaps appear at low dials and higher dials nest more on top.
substitutable: list[int] = [] # token indices eligible for substitution
best_dist: dict[int, float] = {} # index -> distance of its nearest candidate
cand_cache: dict[int, list[tuple[str, float]]] = {}
for i, tok in enumerate(tokens):
if not tok.replace("'", "").isalpha():
continue
low = tok.lower()
if low in _FUNCTION_WORDS:
continue
# Pull a WIDE ladder (24 within dist 13), not just the nearest few: the level
# gates both the COUNT of changes (which words, below) and HOW FAR each one
# drifts (the nearest-skip below), so the top dial needs farther candidates to
# reach for. best_dist still uses the nearest, so word-change ORDER is unchanged.
cands = self.find_candidates(low, max_dist=13.0,
max_results=max(24, n_candidates_per_word))
if not cands:
continue
substitutable.append(i)
best_dist[i] = cands[0][1]
cand_cache[i] = cands
# How many of the eligible words to substitute at this level (monotonic: 0,1,...,N).
n_elig = len(substitutable)
if level <= 0 or n_elig == 0:
k = 0
elif level >= len(LEVEL_P) - 1:
k = n_elig # top level: change everything eligible
else:
import math as _math
k = min(n_elig, max(1, _math.ceil(LEVEL_P[level] * n_elig)))
# Pick the k words with the closest (most convincing) mishearings; tie-break by
# position so the choice is deterministic and nests as the dial rises.
chosen = sorted(substitutable, key=lambda i: (best_dist[i], i))[:k]
chosen_set = set(chosen)
# Per-level: drop the nearest mishearings from each chosen word so high dials reach
# farther (the saturation fix; see LEVEL_SKIP_NEAR). Always keep >= 4 so the beam has
# room and a short candidate ladder doesn't collapse to a single forced choice.
skip_frac = LEVEL_SKIP_NEAR[level]
per_position: list[list[tuple[str, float]] | None] = []
for i, tok in enumerate(tokens):
if i not in chosen_set:
per_position.append(None)
continue
pool = cand_cache[i]
if skip_frac > 0.0 and len(pool) > 4:
drop = min(len(pool) - 4, int(skip_frac * len(pool)))
pool = pool[drop:]
per_position.append(pool)
# Beam search. Each beam = (sequence_so_far: list[str], cumulative_log_prob: float).
beams: list[tuple[list[str], float]] = [([], 0.0)]
for pos, cands in enumerate(per_position):
src_tok = tokens[pos]
if cands is None:
# Pass-through — append the same source token to every beam.
beams = [(beam + [src_tok], score) for beam, score in beams]
continue
expanded: list[tuple[list[str], float]] = []
for beam, score in beams:
used = {w.lower() for w in beam} # words already chosen in this beam
for cand_word, cand_dist in cands:
# Dedup: don't let the same substitute word repeat in one sentence
# (the "cecil ... cecil" bug). Skip if already used in this beam.
if cand_word.lower() in used:
continue
new_seq = beam + [cand_word]
# Score the partial sequence's last-token log-prob under the LM.
# Reuses prefix cache internally; this is fast.
partial_text = self._compose_partial(tokens, pos, new_seq)
lm_score = reranker.score_next_token(partial_text)
# Distance preference scales with the dial (see LEVEL_DIST_PREF): low
# dials prefer the nearest, most natural mishearing; high dials prefer a
# farther, weirder one, so the same word drifts further as the dial rises
# and dial 4 stays distinct from dial 3 even when the substitution count
# has already saturated. Alphabetical micro-term keeps ties deterministic.
dist_pref = LEVEL_DIST_PREF[level] * cand_dist
alpha_tb = -ord(cand_word[0]) * 1e-9
expanded.append((new_seq, score + lm_score + dist_pref + alpha_tb))
if not expanded:
# Every candidate collided with an already-used word; keep the source.
beams = [(beam + [src_tok], score) for beam, score in beams]
continue
# Stable sort by score descending; on tie, earlier item wins (Python sort is stable).
expanded.sort(key=lambda x: -x[1])
beams = expanded[:beam_width]
if not beams:
return sentence
best_seq, _ = beams[0]
return self._restore_caps(sentence, best_seq)
def _compose_partial(self, tokens: list[str], up_to_pos: int,
chosen_so_far: list[str]) -> str:
"""Rebuild the text up through position `up_to_pos` using `chosen_so_far`
for substituted positions and the source for pass-through."""
# chosen_so_far has exactly up_to_pos + 1 entries (one per token through pos)
return "".join(chosen_so_far)
# ---- LM reranker for semantic coherence ----
class LMReranker:
"""Small causal LM that scores partial sentences for semantic coherence during
beam search over phonetic-ghost candidates.
Default model is DistilGPT-2 (~82M params, ~330MB on disk). Loads lazily on first
score() call. CPU-only is fine for ~10-word lyrics at beam_width=8 — scores ~80
short prompts per sentence, finishes in ~1-2 seconds on a modern Mac.
Determinism: scoring is a pure function of (model weights, input text). No sampling.
"""
DEFAULT_MODEL = "distilgpt2"
def __init__(self, model_name: str = DEFAULT_MODEL, device: str = "cpu"):
from transformers import AutoTokenizer, AutoModelForCausalLM
import torch
self._torch = torch
self._tokenizer = AutoTokenizer.from_pretrained(model_name)
self._model = AutoModelForCausalLM.from_pretrained(model_name).to(device).eval()
self._device = device
def score_next_token(self, text: str) -> float:
"""Return the log-probability density of `text` under the LM.
Implemented as average per-token log-likelihood — length-normalized so longer
partial sequences aren't unfairly penalized. Higher is better.
"""
torch = self._torch
ids = self._tokenizer(text, return_tensors="pt").input_ids.to(self._device)
if ids.shape[1] < 2:
return 0.0
with torch.no_grad():
out = self._model(ids, labels=ids)
# out.loss is mean NLL across tokens. log-prob density = -loss.
return float(-out.loss.item())
# ---- Module-level singleton helpers (lazy load) ----
_INDEX: MondegreenIndex | None = None
def get_index(cmudict_path: str | Path = "data/cmudict.dict") -> MondegreenIndex:
global _INDEX
if _INDEX is None:
_INDEX = MondegreenIndex(cmudict_path)
return _INDEX
def substitute(sentence: str, level: int, seed: int = 0,
cmudict_path: str | Path = "data/cmudict.dict") -> str:
"""Convenience: build/cache index then substitute."""
return get_index(cmudict_path).substitute(sentence, level, seed)
# ---- CLI ----
if __name__ == "__main__":
import argparse
p = argparse.ArgumentParser()
p.add_argument("--sentence", required=True)
p.add_argument("--level", type=int, choices=range(5), default=4)
p.add_argument("--seed", type=int, default=42)
p.add_argument("--cmudict", default="data/cmudict.dict")
args = p.parse_args()
idx = MondegreenIndex(args.cmudict)
print(f"CMUdict: {idx.size} words")
print(f"\nlv0 (source): {args.sentence}")
for lv in range(5):
out = idx.substitute(args.sentence, lv, args.seed)
print(f"lv{lv} (p={LEVEL_P[lv]:.2f}, max_d={LEVEL_MAX_DIST[lv]:.0f}): {out}")