80bef6f524
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
131 lines
5.1 KiB
Python
131 lines
5.1 KiB
Python
"""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 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()
|