"""Turn a server reply into something worth *hearing*. The server's persona writes for a chat window: markdown emphasis, bullet lists, emoji, bare URLs. A TTS voice reads those literally ("asterisk asterisk OS colon", "https colon slash slash..."), so everything spoken goes through for_speech() first. Pure string logic, no Qt/audio imports — cheap to unit test (see tests/test_speech_text.py). for_display() is the lighter sibling used for the speech bubble: it drops the markdown *syntax* but keeps emoji and punctuation, since those render fine. """ from __future__ import annotations import re # Pictographs, symbols, flags, dingbats, arrows, box drawing, variation # selectors, ZWJ — anything a voice would either skip or read as a name # ("black right-pointing triangle"). _EMOJI = re.compile( "[" "\U0001F000-\U0001FAFF" # emoji / pictographs / symbols blocks "\U00002190-\U000021FF" # arrows "\U00002300-\U000023FF" # misc technical (⌘ ⏱ …) "\U000025A0-\U000027BF" # geometric shapes, misc symbols, dingbats "\U00002B00-\U00002BFF" # extra arrows / shapes "\U0000FE00-\U0000FE0F" # variation selectors "\U0001F1E6-\U0001F1FF" # regional indicators (flags) "\U0000200D" # zero-width joiner "]+", flags=re.UNICODE, ) # Characters that are markup or decoration rather than speech. Kept out of # the spoken text entirely; ordinary punctuation (. , ! ? ; : ' " ( ) -) is # preserved because it shapes prosody. _UNSPEAKABLE = re.compile(r"[*_#`~^|<>\\{}\[\]/=+@©®™•·–—]+") _FENCED_CODE = re.compile(r"```.*?```", re.DOTALL) _INLINE_CODE = re.compile(r"`([^`]*)`") _MD_IMAGE = re.compile(r"!\[([^\]]*)\]\([^)]*\)") _MD_LINK = re.compile(r"\[([^\]]+)\]\([^)]*\)") _URL = re.compile(r"\b(?:https?://|www\.)\S+") _HEADING = re.compile(r"^\s{0,3}#{1,6}\s*", re.MULTILINE) _BLOCKQUOTE = re.compile(r"^\s{0,3}>\s?", re.MULTILINE) _RULE = re.compile(r"^\s*([-*_=])(?:\s*\1){2,}\s*$", re.MULTILINE) _BULLET = re.compile(r"^\s*(?:[-*+•·]|\d+[.)])\s+", re.MULTILINE) _EMPHASIS = re.compile(r"(\*{1,3}|_{1,3})(\S(?:.*?\S)?)\1", re.DOTALL) _TABLE_PIPE = re.compile(r"[ \t]*\|[ \t]*") # Symbols worth saying out loud rather than dropping — a bare "&" read as # nothing turns "R&D" into "RD". Split in two because the non-ASCII ones sit # inside the arrow/symbol blocks _EMOJI strips, so they have to be worded # before that pass; the ASCII ones must wait until *after* markdown parsing # (an "=" turned into " equals " would stop a "====" rule matching _RULE). _PRE_SPOKEN_SYMBOLS = { "→": " to ", "×": " by ", "°": " degrees ", "≈": " about ", } _SPOKEN_SYMBOLS = { "&": " and ", "%": " percent ", "@": " at ", "+": " plus ", "=": " equals ", } # Abbreviations a voice spells out letter by letter ("eee gee") because the # periods make them look like sentence boundaries. Written out instead — this # has to run before _UNSPEAKABLE strips anything, and the trailing \.? keeps # "etc" working with or without its period. Word-bounded so "vs" inside a # filename is left alone. _SPOKEN_ABBREVIATIONS = ( (re.compile(r"\be\.g\.?(?=\s|$)", re.IGNORECASE), "for example"), (re.compile(r"\bi\.e\.?(?=\s|$)", re.IGNORECASE), "that is"), (re.compile(r"\betc\.?(?=\s|$)", re.IGNORECASE), "and so on"), (re.compile(r"\bvs\.?(?=\s|$)", re.IGNORECASE), "versus"), (re.compile(r"\baka\b", re.IGNORECASE), "also known as"), (re.compile(r"\bw/(?=\s)", re.IGNORECASE), "with"), # "PR #42" -> "PR number 42"; a bare "#" is markup and _UNSPEAKABLE drops it. (re.compile(r"#(?=\d)"), "number "), # A long option's dashes are punctuation to the eye and syllables to the ear # ("dash dash force"). Only the doubled form: a single hyphen has to survive # for "bolt-pet" and "up-to-date", and requiring a word character after it # keeps a "---" horizontal rule intact for _RULE to strip. (re.compile(r"(? str: text = _FENCED_CODE.sub(" (code) ", text) text = _INLINE_CODE.sub(r"\1", text) text = _MD_IMAGE.sub(r"\1", text) text = _MD_LINK.sub(r"\1", text) text = _RULE.sub("", text) text = _HEADING.sub("", text) text = _BLOCKQUOTE.sub("", text) text = _EMPHASIS.sub(r"\2", text) if not keep_emoji: text = _EMOJI.sub(" ", text) return text def _bullets_to_sentences(text: str) -> str: """A read-aloud list needs pauses where the eye would see line breaks, otherwise "OS Linux Uptime 1 day CPU load moderate" runs together.""" lines = [_BULLET.sub("", line).strip() for line in text.splitlines()] lines = [line for line in lines if line] if len(lines) < 2: return lines[0] if lines else "" # A bullet like "OS: Linux" ends mid-thought — give the voice a full stop # so consecutive items don't slur into one run-on sentence. return " ".join(line if line[-1] in ".!?:,;" else line + "." for line in lines) def for_speech(text: str) -> str: """Plain prose for the TTS engine: no markdown, no emoji, no bare URLs, no stray symbols that would be read out character by character.""" text = (text or "").strip() if not text: return "" for symbol, spoken in _PRE_SPOKEN_SYMBOLS.items(): text = text.replace(symbol, spoken) # Before the markdown pass, so "#42" still has its "#" to word and a real # "## Heading" (no digit after the hashes) is left for _HEADING to strip. for pattern, spoken in _SPOKEN_ABBREVIATIONS: text = pattern.sub(spoken, text) text = _strip_markdown(text, keep_emoji=False) text = _URL.sub(" link ", text) text = _TABLE_PIPE.sub(", ", text) text = _bullets_to_sentences(text) for symbol, spoken in _SPOKEN_SYMBOLS.items(): text = text.replace(symbol, spoken) text = _UNSPEAKABLE.sub(" ", text) text = _MULTI_PUNCT.sub(".", text) text = _MULTI_SPACE.sub(" ", text) text = re.sub(r"\s+([.,!?;:])", r"\1", text) return text.strip() def is_question(text: str) -> bool: """True if the reply asks the user anything — the cue for the pet to keep listening instead of making you say the wake word again. Anywhere in the reply counts, not only the end. An earlier version required a *trailing* '?' on the theory that "What time is it? It's 7:15." isn't waiting on an answer, and that's true of that sentence but wrong far more often: Bolt routinely asks first and then keeps talking ("Want me to fix it? I'd start with the config."), and refusing to listen there is the case that actually costs you a wake word. The cheap failure is the other direction — an unwanted extra listen ends itself on `VAD_GRACE_SECONDS` of silence, and `FOLLOW_UP_MAX_TURNS` caps the chain. The test runs on the *spoken* form, so a '?' that only exists inside a stripped code block, a URL, or a markdown link target doesn't count.""" return "?" in for_speech(text) def for_display(text: str) -> str: """What the speech bubble shows: markdown syntax removed (the bubble can't render it) but emoji and layout-ish punctuation left alone.""" text = (text or "").strip() if not text: return "" text = _strip_markdown(text, keep_emoji=True) lines = [_BULLET.sub("• ", line).strip() for line in text.splitlines()] text = " ".join(line for line in lines if line) return _MULTI_SPACE.sub(" ", text).strip()