"""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 ", } _MULTI_SPACE = re.compile(r"[ \t]+") _MULTI_PUNCT = re.compile(r"(?:\s*\.){2,}") def _strip_markdown(text: str, *, keep_emoji: bool) -> 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) 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 *ends* by asking the user something — the cue for the pet to keep listening instead of making you say the wake word again. Deliberately only looks at the end. A reply that asks something in passing ("What time is it? It's 7:15.") isn't waiting on an answer, whereas one that finishes on a question mark is. The test runs on the spoken form, so a '?' that only exists inside a stripped code block or a URL doesn't count, and trailing decoration (emoji, quotes, brackets) is peeled off first so "Ready to go? 🚀" still reads as a question.""" spoken = for_speech(text) while spoken and not (spoken[-1].isalnum() or spoken[-1] == "?"): spoken = spoken[:-1] return spoken.endswith("?") 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()