Spaces:
Running on Zero
Running on Zero
| """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 | |
| 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) | |
| 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}") | |