From 80daa0218c1d7f39985f6181fb86bca6fca33dc8 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Tue, 8 Sep 2026 10:44:54 +0100 Subject: [PATCH 1/2] feat: speak filenames and paths --- docs/speech-normalization.md | 52 +++++++ stackvox/cli.py | 9 +- stackvox/text.py | 266 +++++++++++++++++++++++++++++++---- tests/test_cli.py | 1 + tests/test_text.py | 100 +++++++++++-- 5 files changed, 394 insertions(+), 34 deletions(-) diff --git a/docs/speech-normalization.md b/docs/speech-normalization.md index 0808c0a..edb50b7 100644 --- a/docs/speech-normalization.md +++ b/docs/speech-normalization.md @@ -61,6 +61,7 @@ Individual stages also exposed for composability (e.g. `expand_numbers(text)`, ### Pipeline order (order matters — encodes real bugs we hit) +1. **Filenames** (if `filenames`): file refs, then bare filenames/paths (see below). 1. **Markdown** (if `markdown`): fenced/inline code, images, links→text, reference links, headings, blockquotes, horizontal rules, list markers, emphasis (leave lone `_` for snake_case), tables per `tables`. 2. **Emoji** (if `strip_emoji`). 3. **Numbers** (if `expand_numbers`): strip thousands commas (`1,198.9`→`1198.9`) *then* decimals→words (`1198.9`→`1198 point 9`). @@ -72,6 +73,57 @@ Individual stages also exposed for composability (e.g. `expand_numbers(text)`, > Ordering note to preserve: **currency/unit expansion must precede the > decimal-point split**, or `£1.63` becomes `£1 point 63`. See `read-aloud.py`. +### Filenames and paths + +espeak already spells extensions correctly by itself — `.md` voices as "em dee", +`.tf` as "tee eff", `.json` as "jason" — so these stages **leave the extension +alone**. Respelling it would be redundant and would regress the cases espeak +gets right. What espeak gets wrong is everything around the extension: + +| Problem | espeak gives | We emit | +|---|---|---| +| the dot is silent | `README.md` → "readmee-emdee" | `README dot md` | +| hyphens are swallowed | `speech-normalization` → one word | `speech normalization` | +| a leading dot is silent | `.github` → "github" | `dot github` | +| `~/` glues | `~/x` → "tilde-slash x" | `home slash x` | +| `.yml` → "immle" | `ci.yml` → "sigh immle" | `ci dot yaml` | + +Two stages, both gated on `filenames`: + +- `speak_file_refs` — needs a `:line` suffix. Emits `line N of in `: + the line number leads because spoken aloud it's the signal, and the directory + trails as a prepositional phrase, which is how a person says it. + `src/lib/cli.py:100-118` → "lines 100 to 118 of cli dot py in src slash lib". + The `:line` is the trigger, so times, ratios, verses (`12:30`, `John 3:16`) + and dotted versions (`1.2.3`) are untouched. +- `speak_file_names` — no suffix needed. Emits the path in reading order: + `docs/speech-normalization.md` → "docs slash speech normalization dot md". + +**Refs run first**, because that stage rewrites its matches into prose with no +dotted token left, so the two never fight over one reference. + +**The bare-name stage is gated on an extension allowlist** (`_FILE_EXTENSIONS`), +not a general `word.word` rule. Prose is full of lookalikes espeak already reads +correctly, and an allowlist excludes all of them for free: attribute access +(`os.path.join`, `self.assertEqual`), abbreviations (`e.g.`, `i.e.`, `U.S.`) and +domains (`example.com`, `claude.ai`). Two deliberate carve-outs: + +- **Single-letter extensions are absent** (`.c`, `.h`, `.r`) — they would rewrite + initials like `J.R.R` into "J dot R dot R". Those files keep espeak's existing + reading rather than risk a prose regression. +- `_NOT_FILENAMES` holds dotted product names with real extensions — `Node.js`, + `Next.js` — which are read as one name, not as a file. + +> Ordering note to preserve: **the filename stages run first**. They consume the +> `:line` digits before the number stages see them (else `:42` meets the decimal +> split), and spacing the dot leaves the stem a standalone word — which is what +> lets the dev-term dict still fix `cli.py` to "C L I dot py" instead of gluing +> it into `C L I.py`. + +Own flag, `filenames`, deliberately **not** `dev_terms`: the acronym dict and +filename handling are unrelated, and coupling them meant `--no-dev-terms` +silently disabled file refs too. + ## 4. CLI surface Backward-compatible additions to the existing `speak`/`say`: diff --git a/stackvox/cli.py b/stackvox/cli.py index 77d8bcc..7475096 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -87,7 +87,7 @@ def _configure_logging() -> None: ;; esac - local norm_flags="--no-markdown --no-dev-terms --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale" + local norm_flags="--no-markdown --no-dev-terms --no-filenames --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --code-blocks --code-placeholder --strip-emoji --no-terminal-stops --locale" case "$subcommand" in speak) @@ -220,6 +220,12 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) - action="store_false", help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)", ) + parser.add_argument( + "--no-filenames", + dest="filenames", + action="store_false", + help="Leave filenames and paths as written (no 'README dot md', no 'line 42 of cli dot py')", + ) parser.add_argument( "--no-expand-units", dest="expand_units", @@ -294,6 +300,7 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict: "markdown": args.markdown, "pronunciations": _load_pronunciations(args.pronunciations), "dev_terms": args.dev_terms, + "filenames": args.filenames, "expand_units": args.expand_units, "expand_numbers": args.expand_numbers, "pauses": args.pauses, diff --git a/stackvox/text.py b/stackvox/text.py index 8cff294..870de36 100644 --- a/stackvox/text.py +++ b/stackvox/text.py @@ -21,6 +21,7 @@ "versions_to_words", "decimals_to_words", "speak_file_refs", + "speak_file_names", "expand_units", "apply_pronunciations", "shape_pauses", @@ -113,45 +114,257 @@ def decimals_to_words(text: str) -> str: # --------------------------------------------------------------------------- # -# File & line references # +# File & path references # # --------------------------------------------------------------------------- # -# `engine.py:42` reads as "…dot py colon forty two" — the dots and colon get -# voiced literally. Spoken aloud the line number is the signal and the filename -# is noise, so lead with "line N of " (how a person actually says it) and -# soften the path: drop directories to the basename, and voice the dotted name -# word by word. +# espeak already spells extensions correctly on its own: ".md" voices as "em +# dee", ".tf" as "tee eff", ".json" as "jason", so these stages deliberately +# leave the extension alone. What espeak gets wrong is everything around it: +# +# * the dot between stem and extension is SILENT ("README.md" -> "readmee-emdee") +# * a hyphen inside a name is swallowed ("speech-normalization" -> one word) +# * a leading dot is silent, so ".github" reads as "github" +# * "~/" glues into "tilde-slash" +# * ".yml" reads as "immle" (".yaml" is fine) +# +# So: voice the dot, space the hyphens, voice a leading dot, say "home" for "~", +# and alias the one bad extension. Directory segments are spoken with "slash" +# between them, which is how a person reads a path aloud. + +# Only these extensions mark a dotted token as a filename. An allowlist, not a +# general "word.word" rule, because ordinary prose is full of lookalikes espeak +# ALREADY voices correctly and which must not be touched: attribute access +# (os.path.join, self.assertEqual), abbreviations (e.g., i.e., U.S.), and +# domains (example.com, claude.ai) all fall outside it for free. +# Single-letter extensions (.c, .h, .r) are deliberately absent: they would +# rewrite initials like "J.R.R" into "J dot R dot R". Those files keep espeak's +# existing reading rather than risk a prose regression. +_FILE_EXTENSIONS = frozenset( + [ + "py", + "pyi", + "pyx", + "ipynb", + "rb", + "rs", + "go", + "java", + "kt", + "kts", + "swift", + "cpp", + "hpp", + "cc", + "cxx", + "cs", + "php", + "lua", + "pl", + "scala", + "clj", + "cljs", + "ex", + "exs", + "erl", + "vim", + "el", + "ts", + "tsx", + "js", + "jsx", + "mjs", + "cjs", + "vue", + "svelte", + "astro", + "json", + "yaml", + "yml", + "toml", + "ini", + "cfg", + "conf", + "env", + "xml", + "csv", + "tsv", + "properties", + "lock", + "md", + "mdx", + "rst", + "txt", + "adoc", + "tex", + "html", + "htm", + "css", + "scss", + "sass", + "less", + "svg", + "tf", + "tfvars", + "tfstate", + "mk", + "cmake", + "gradle", + "bzl", + "proto", + "graphql", + "gql", + "sql", + "sh", + "bash", + "zsh", + "fish", + "ps1", + "bat", + "dockerfile", + "gitignore", + "editorconfig", + "npmrc", + "nvmrc", + "log", + "wav", + "mp3", + "pdf", + "png", + "jpg", + "jpeg", + "gif", + "webp", + "zip", + "tar", + "gz", + "whl", + ] +) + +# Extensions espeak mispronounces, mapped to a spelling it reads correctly. +# ".yml" -> "immle"; the "yaml" spelling voices as "yaml". +_EXTENSION_ALIASES: dict[str, str] = {"yml": "yaml"} + +# Dotted tokens that LOOK like "name.ext" with a real extension but are read as +# a single product name. Compared lowercased against the whole matched token. +_NOT_FILENAMES = frozenset({"node.js", "next.js", "nuxt.js", "vue.js", "ember.js", "backbone.js"}) + + +def _speak_segment(segment: str) -> str: + """Space out a hyphenated path segment: "speech-normalization" -> "speech + normalization". espeak swallows the hyphen and runs the halves together. + Underscores are left alone; espeak doesn't voice them.""" + return segment.replace("-", " ") + + +def _speak_basename(name: str) -> str: + """``speech-normalization.md`` -> "speech normalization dot md". + + Every dot becomes a spoken "dot" (espeak drops it), hyphens become spaces, + and the final extension is run through the alias map. The extension itself + is otherwise untouched; espeak spells it correctly already. + """ + segments = name.split(".") + segments[-1] = _EXTENSION_ALIASES.get(segments[-1].lower(), segments[-1]) + return " dot ".join(_speak_segment(segment) for segment in segments) + + +def _speak_dirs(path: str) -> str: + """``src/lib/`` -> "src slash lib"; ``~/.config/`` -> "home slash dot config". + + A leading dot and a ``~`` are both mis-voiced by espeak (silent, and + "tilde-slash" respectively), so they're spelled out here. + """ + spoken: list[str] = [] + for segment in path.split("/"): + if not segment or segment == ".": + continue + if segment == "~": + spoken.append("home") + elif segment == "..": + spoken.append("dot dot") + elif segment.startswith("."): + spoken.append("dot " + _speak_segment(segment[1:])) + else: + spoken.append(_speak_segment(segment)) + return " slash ".join(spoken) + + +def _is_filename(basename: str) -> bool: + """True when the final dotted component is a known file extension.""" + if basename.lower() in _NOT_FILENAMES: + return False + return basename.rsplit(".", 1)[-1].lower() in _FILE_EXTENSIONS + + +# Directory segments, captured. The inner segment allows empty, so an absolute +# "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/abs/path/" is consumed by the match rather than left stranded in the text. +_DIRS = r"((?:[\w.~-]*/)*)" +_BASENAME = r"([A-Za-z0-9_-]+(?:\.[A-Za-z][\w-]*)+)" # name with >=1 letter-initial extension _FILE_REF = re.compile( r"(?=1 letter-initial extension - r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column - r"(?!\w)" + + _DIRS + + _BASENAME + + r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column + + r"(?!\w)" ) +# Same shape without the ":line" suffix. The trailing guard allows a following +# "." so a sentence-final "See README.md." still matches. +_FILE_NAME = re.compile(r"(? str: - """Turn ``path/file.ext:line`` refs into spoken "line N of file ext". + """Turn ``path/file.ext:line`` refs into spoken "line N of file dot ext in path". + + ``engine.py:42`` -> "line 42 of engine dot py"; ``src/cli.py:100-118`` -> + "lines 100 to 118 of cli dot py in src"; ``foo.ts:666:10`` -> "line 666, + column 10 of foo dot ts". The line number leads because spoken aloud it's + the signal, and the directory trails as a prepositional phrase, which is how + a person says it. - ``engine.py:42`` -> "line 42 of engine py"; ``cli.py:100-118`` -> "lines 100 - to 118 of cli py"; ``foo.ts:666:10`` -> "line 666, column 10 of foo ts". The - ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``, - ``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched — + The ``:line`` suffix is the trigger, so bare times/ratios/verses (``12:30``, + ``3:1``, ``John 3:16``) and dotted versions (``1.2.3``) are left untouched -- none of them carry a dotted-filename before the colon. """ def repl(match: re.Match[str]) -> str: - basename = match.group(1).replace(".", " ") - start, end, column = match.group(2), match.group(3), match.group(4) + dirs, basename = match.group(1), match.group(2) + start, end, column = match.group(3), match.group(4), match.group(5) if end: location = f"lines {start} to {end}" else: location = f"line {start}" + (f", column {column}" if column else "") - return f"{location} of {basename}" + spoken = f"{location} of {_speak_basename(basename)}" + directories = _speak_dirs(dirs) + return f"{spoken} in {directories}" if directories else spoken return _FILE_REF.sub(repl, text) +def speak_file_names(text: str) -> str: + """Voice a bare filename or path, with no ``:line`` suffix needed. + + ``README.md`` -> "README dot md"; ``docs/speech-normalization.md`` -> "docs + slash speech normalization dot md". Gated on a known extension + (:data:`_FILE_EXTENSIONS`), so prose lookalikes espeak already reads + correctly (``os.path.join``, ``e.g.``, ``example.com``) pass through. + + Run this AFTER :func:`speak_file_refs`: that stage rewrites its own matches + into prose containing no dotted token, so the two never fight over one ref. + """ + + def repl(match: re.Match[str]) -> str: + dirs, basename = match.group(1), match.group(2) + if not _is_filename(basename): + return match.group(0) + spoken = _speak_basename(basename) + directories = _speak_dirs(dirs) + return f"{directories} slash {spoken}" if directories else spoken + + return _FILE_NAME.sub(repl, text) + + # --------------------------------------------------------------------------- # # Pronunciations # # --------------------------------------------------------------------------- # @@ -360,17 +573,21 @@ def _shape_paragraph( text: str, *, pronunciations: dict[str, str] | None, - dev_terms_flag: bool, + filenames_flag: bool, expand_units_flag: bool, expand_numbers_flag: bool, pauses_flag: bool, locale: str, ) -> str: - # File refs first: it consumes the ":line" digits (so the number stages see a - # plain "line 42", not a decimal) and forms the spoken basename before the - # dev-term dict runs over it. - if dev_terms_flag: + # Filenames first, for two reasons: the ref stage consumes the ":line" digits + # (so the number stages see a plain "line 42", not a decimal), and spacing the + # dot leaves the stem a standalone word, which is what lets the dev-term dict + # below still fix "cli" -> "C L I" without gluing it to the extension. + # Refs before bare names: the ref stage rewrites its matches into prose with + # no dotted token left, so the two never fight over the same reference. + if filenames_flag: text = speak_file_refs(text) + text = speak_file_names(text) if expand_numbers_flag: text = strip_thousands_separators(text) if pauses_flag: @@ -391,6 +608,7 @@ def normalize_for_speech( markdown: bool = True, pronunciations: dict[str, str] | None = None, dev_terms: bool = True, + filenames: bool = True, expand_units: bool = True, expand_numbers: bool = True, pauses: bool = True, @@ -429,7 +647,7 @@ def normalize_for_speech( shaped = _shape_paragraph( para, pronunciations=effective_pronunciations, - dev_terms_flag=dev_terms, + filenames_flag=filenames, expand_units_flag=expand_units_flag, expand_numbers_flag=expand_numbers_flag, pauses_flag=pauses, diff --git a/tests/test_cli.py b/tests/test_cli.py index 92f7764..d0c8db8 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -129,6 +129,7 @@ def _norm_ns(text=None, file=None, **overrides): markdown=True, pronunciations=None, dev_terms=True, + filenames=True, expand_units=True, expand_numbers=True, pauses=True, diff --git a/tests/test_text.py b/tests/test_text.py index bf6e11c..3efe756 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -8,6 +8,7 @@ markdown_to_paragraphs, normalize_for_speech, shape_pauses, + speak_file_names, speak_file_refs, strip_emoji, strip_thousands_separators, @@ -55,23 +56,30 @@ def test_semver_normalizes_end_to_end(): assert "0 point 7.0" not in out -# --- file & line references ------------------------------------------------ +# --- file & path references ------------------------------------------------ def test_file_ref_leads_with_line_then_file(): - assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 service ts" + """Every dot is spoken, including one inside a multi-part name.""" + assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 dot service dot ts" -def test_file_ref_drops_directories_to_basename(): - assert speak_file_refs("Open /abs/path/to/module.py:7.") == "Open line 7 of module py." +def test_file_ref_trails_directories_after_the_file(): + assert speak_file_refs("Open /abs/path/to/module.py:7.") == ( + "Open line 7 of module dot py in abs slash path slash to." + ) + + +def test_file_ref_without_directories_has_no_trailing_clause(): + assert speak_file_refs("engine.py:42") == "line 42 of engine dot py" def test_file_ref_line_range(): - assert speak_file_refs("cli.py:100-118") == "lines 100 to 118 of cli py" + assert speak_file_refs("src/cli.py:100-118") == "lines 100 to 118 of cli dot py in src" def test_file_ref_line_and_column(): - assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo ts" + assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo dot ts" def test_file_ref_leaves_times_ratios_verses_untouched(): @@ -85,12 +93,86 @@ def test_file_ref_leaves_dotted_versions_untouched(): def test_file_ref_normalizes_end_to_end(): out = normalize_for_speech("See `engine.py:42` for the fix.", markdown=True) - assert out == "See line 42 of engine py for the fix." + assert out == "See line 42 of engine dot py for the fix." + + +def test_file_name_speaks_the_dot(): + assert speak_file_names("See README.md for setup.") == "See README dot md for setup." + + +def test_file_name_at_end_of_sentence(): + assert speak_file_names("Check pyproject.toml.") == "Check pyproject dot toml." + + +def test_file_name_speaks_path_segments_with_slash(): + actual = speak_file_names("docs/speech-normalization.md") + assert actual == "docs slash speech normalization dot md" + + +def test_file_name_spaces_hyphens_espeak_swallows(): + assert speak_file_names("read-aloud.py") == "read aloud dot py" + + +def test_file_name_voices_leading_dot_and_home(): + assert speak_file_names(".github/workflows/deploy.yaml") == ( + "dot github slash workflows slash deploy dot yaml" + ) + assert speak_file_names("~/.config/stackvox/config.toml") == ( + "home slash dot config slash stackvox slash config dot toml" + ) + + +def test_file_name_aliases_yml_espeak_reads_as_immle(): + assert speak_file_names("ci.yml") == "ci dot yaml" + + +def test_file_name_multi_dot_name(): + assert speak_file_names("vite.config.ts") == "vite dot config dot ts" + + +def test_file_name_requires_a_known_extension(): + """Attribute access, abbreviations and domains espeak already reads + correctly must survive the bare-filename matcher untouched.""" + for text in [ + "os.path.join", + "self.assertEqual", + "Use e.g. this one", + "i.e. that one", + "U.S. policy", + "example.com", + "claude.ai", + "J.R.R Tolkien", + ]: + assert speak_file_names(text) == text + + +def test_file_name_leaves_dotted_js_product_names_untouched(): + assert speak_file_names("Node.js and Next.js") == "Node.js and Next.js" + + +def test_file_name_leaves_refs_to_the_ref_stage(): + """speak_file_refs runs first and leaves no dotted token behind, so the + bare-name stage must not re-touch its output.""" + once = speak_file_refs("src/cli.py:42") + assert speak_file_names(once) == once + + +def test_spacing_the_dot_lets_the_dev_dict_fix_the_stem(): + """ "cli.py" glued reads as "kligh dot py"; with the dot spaced, the stem is a + standalone word the dev-term dict can correct.""" + assert normalize_for_speech("Look at cli.py.", markdown=False) == "Look at C L I dot py." + + +def test_filenames_disabled(): + out = normalize_for_speech("See engine.py:42 and README.md.", markdown=False, filenames=False) + assert out == "See engine.py:42 and README.md." -def test_file_ref_disabled_with_dev_terms(): +def test_filenames_independent_of_dev_terms(): + """Filenames have their own flag: turning the acronym dict off must not + silently disable them.""" out = normalize_for_speech("See engine.py:42.", markdown=False, dev_terms=False) - assert out == "See engine.py:42." + assert out == "See line 42 of engine dot py." # --- units ----------------------------------------------------------------- From de7055faa7b5ccc6fad83b84a5ddc6f77f8d69f1 Mon Sep 17 00:00:00 2001 From: StuBehan Date: Tue, 8 Sep 2026 12:09:41 +0100 Subject: [PATCH 2/2] feat: speak semantic version ranges and pre-release tags --- docs/speech-normalization.md | 37 +++++++++++++++++ stackvox/text.py | 79 ++++++++++++++++++++++++++++++++++++ tests/test_text.py | 65 +++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/docs/speech-normalization.md b/docs/speech-normalization.md index edb50b7..780a458 100644 --- a/docs/speech-normalization.md +++ b/docs/speech-normalization.md @@ -62,6 +62,7 @@ Individual stages also exposed for composability (e.g. `expand_numbers(text)`, ### Pipeline order (order matters — encodes real bugs we hit) 1. **Filenames** (if `filenames`): file refs, then bare filenames/paths (see below). +1. **Versions** (if `expand_numbers`): range operators, pre-release suffixes, wildcards (see below). 1. **Markdown** (if `markdown`): fenced/inline code, images, links→text, reference links, headings, blockquotes, horizontal rules, list markers, emphasis (leave lone `_` for snake_case), tables per `tables`. 2. **Emoji** (if `strip_emoji`). 3. **Numbers** (if `expand_numbers`): strip thousands commas (`1,198.9`→`1198.9`) *then* decimals→words (`1198.9`→`1198 point 9`). @@ -73,6 +74,42 @@ Individual stages also exposed for composability (e.g. `expand_numbers(text)`, > Ordering note to preserve: **currency/unit expansion must precede the > decimal-point split**, or `£1.63` becomes `£1 point 63`. See `read-aloud.py`. +### Semantic versions + +`versions_to_words` handles the dotted digits (`1.2.3` → "1 point 2 point 3"). +`speak_versions` handles everything else in a semver string, all of which espeak +gets wrong: + +| Problem | espeak gives | We emit | +|---|---|---| +| pre-release glues to the core | `1.2.3-rc.1` → "…three-arsee-one" | `1.2.3, rc 1` | +| `^`, `>`, `<` are **silent** | `^1.2.3` sounds identical to a pin | `compatible with 1.2.3` | +| `>=` split by our own `=` rule | "equals 1.2.3", meaning inverted | `at least 1.2.3` | +| wildcard dot is silent | `1.x` → "one ex" | `1 dot x` | + +Operators map semantically: `^`→"compatible with", `>=`→"at least", +`<=`→"at most", `>`→"above", `<`→"below", `==`→"exactly", `!=`→"not equal to". +`~` is left to `expand_units`, which already maps it to "about"; accidentally +the right reading for a tilde range. + +Two guards worth keeping: + +- **Each operator requires a following digit**, which keeps the rules in version + context. The captured boundary character is re-emitted with a space, so glued + forms like pip's `requests>=2.0` and `arr[i]<5` don't fuse. +- **A caret only counts at a token start.** `x^2` and `(a+b)^2` are + exponentiation, not caret ranges. + +> Ordering note to preserve: **`speak_versions` must run before `expand_units`**. +> The `\s*=\s*` → " equals " rule would otherwise split `>=` into a bare `>` +> (which espeak voices as *nothing*) plus "equals", so `>=1.2.3` came out as +> "equals 1 point 2 point 3", the opposite of what it means. + +**Two-part versions are deliberately untouched.** `3.11` reads "3 point 1 1", +not "3 point eleven", because a two-part version is indistinguishable from a +real decimal where digit-by-digit is correct (`770.72` → "770 point 7 2"). +Fixing it needs a context heuristic, not a rule change. + ### Filenames and paths espeak already spells extensions correctly by itself — `.md` voices as "em dee", diff --git a/stackvox/text.py b/stackvox/text.py index 870de36..de74909 100644 --- a/stackvox/text.py +++ b/stackvox/text.py @@ -12,6 +12,7 @@ from __future__ import annotations import re +from collections.abc import Callable __all__ = [ "normalize_for_speech", @@ -19,6 +20,7 @@ "strip_emoji", "strip_thousands_separators", "versions_to_words", + "speak_versions", "decimals_to_words", "speak_file_refs", "speak_file_names", @@ -103,6 +105,81 @@ def versions_to_words(text: str) -> str: return _VERSION.sub(lambda m: m.group(0).replace(".", " point "), text) +# --------------------------------------------------------------------------- # +# Semantic versions # +# --------------------------------------------------------------------------- # +# `versions_to_words` handles the dotted digits. This handles everything else +# in a semver string, all of which espeak gets wrong: +# +# * a pre-release suffix GLUES to the core: "1.2.3-rc.1" voices as +# "one point two point three-arsee-one" (hyphen swallowed, suffix dot silent) +# * "^", ">" and "<" are SILENT, so "^1.2.3" is indistinguishable from a pin +# * ">=" is broken by our own `=` rule below, which splits it into "> equals", +# and a bare ">" voices as nothing, so ">=1.2.3" says "equals 1.2.3", +# inverting the meaning. espeak reads an intact ">=" correctly, so this stage +# must consume the operator BEFORE `expand_units` sees the "=". +# * "1.x" voices as "one ex" (dot silent) +# +# "~" is left to `expand_units`, which already maps it to "about"; accidentally +# the right reading for a tilde range. + +# Longest operators first: ">=" must win before ">". Each requires a following +# digit (optionally "v"-prefixed), which keeps the rules in version context and +# off ordinary punctuation. The leading `(\S?)` captures whatever non-space +# character precedes the operator and re-emits it with a space, so glued forms +# like pip's "requests>=2.0" or "arr[i]<5" don't fuse into "requestsat least". +_VERSION_OPERATORS: list[tuple[str, str]] = [ + (r"(\S?)>=\s*(?=v?\d)", "at least "), + (r"(\S?)<=\s*(?=v?\d)", "at most "), + (r"(\S?)==\s*(?=v?\d)", "exactly "), + (r"(\S?)!=\s*(?=v?\d)", "not equal to "), + (r"(\S?)>\s*(?=v?\d)", "above "), + (r"(\S?)<\s*(?=v?\d)", "below "), +] + +# "^" only at a token start: "x^2" and ")^2" are exponentiation, not a range. +_CARET_RANGE = re.compile(r"(?=2 parts, so a bare "100" can't match) followed by a +# "-prerelease" or "+build" suffix. +_VERSION_SUFFIX = re.compile(r"(? Callable[[re.Match[str]], str]: + """Replacement that re-emits the captured boundary character with a space.""" + + def repl(match: re.Match[str]) -> str: + boundary = match.group(1) + return f"{boundary} {spoken}" if boundary else spoken + + return repl + + +def speak_versions(text: str) -> str: + """Voice the non-numeric parts of a semantic version. + + ``1.2.3-rc.1`` -> "1.2.3, rc 1"; ``>=1.2.3`` -> "at least 1.2.3"; + ``^1.2.3`` -> "compatible with 1.2.3"; ``1.x`` -> "1 dot x". The dotted + digits are left for :func:`versions_to_words`, which runs later, so this + stage only unglues and names things espeak drops. + + Must run BEFORE :func:`expand_units`, whose ``=`` rule would otherwise split + ``>=`` into a silent ``>`` plus "equals". + """ + for pattern, words in _VERSION_OPERATORS: + text = re.sub(pattern, _operator_repl(words), text) + text = _CARET_RANGE.sub("compatible with ", text) + # Suffix: comma for a beat, then the tag's own dots and hyphens as spaces. + text = _VERSION_SUFFIX.sub( + lambda m: f"{m.group(1)}, {re.sub(r'[.-]', ' ', m.group(2)[1:])}", + text, + ) + return _VERSION_WILDCARD.sub(r"\1 dot \2", text) + + def decimals_to_words(text: str) -> str: """1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare "." between digits, which TTS can otherwise read as a full stop.""" @@ -589,6 +666,8 @@ def _shape_paragraph( text = speak_file_refs(text) text = speak_file_names(text) if expand_numbers_flag: + # Version ranges before units: the "=" unit rule would split ">=". + text = speak_versions(text) text = strip_thousands_separators(text) if pauses_flag: text = shape_pauses(text) diff --git a/tests/test_text.py b/tests/test_text.py index 3efe756..e4b7bb3 100644 --- a/tests/test_text.py +++ b/tests/test_text.py @@ -10,6 +10,7 @@ shape_pauses, speak_file_names, speak_file_refs, + speak_versions, strip_emoji, strip_thousands_separators, versions_to_words, @@ -56,6 +57,70 @@ def test_semver_normalizes_end_to_end(): assert "0 point 7.0" not in out +# --- semantic versions ----------------------------------------------------- + + +def test_version_prerelease_is_ungluded_from_the_core(): + """ "1.2.3-rc.1" glues into "three-arsee-one": espeak swallows the hyphen and + drops the suffix dot.""" + assert speak_versions("1.2.3-rc.1") == "1.2.3, rc 1" + assert speak_versions("1.2.3-beta.2") == "1.2.3, beta 2" + + +def test_version_build_metadata(): + assert speak_versions("1.2.3+build.5") == "1.2.3, build 5" + + +def test_version_operators_are_spoken_semantically(): + assert speak_versions("^1.2.3") == "compatible with 1.2.3" + assert speak_versions(">=1.2.3") == "at least 1.2.3" + assert speak_versions("<=2.0.0") == "at most 2.0.0" + assert speak_versions(">1.0.0") == "above 1.0.0" + assert speak_versions("<2.0.0") == "below 2.0.0" + assert speak_versions("==1.2.3") == "exactly 1.2.3" + assert speak_versions("!=1.2.3") == "not equal to 1.2.3" + + +def test_version_operator_keeps_a_glued_boundary_separate(): + """pip writes the operator glued to the package name.""" + assert speak_versions("requests>=2.0") == "requests at least 2.0" + assert speak_versions("arr[i]<5") == "arr[i] below 5" + + +def test_caret_exponent_is_not_a_version_range(): + """ "x^2" is exponentiation; only a token-initial caret is a caret range.""" + assert speak_versions("x^2 plus 1") == "x^2 plus 1" + assert speak_versions("(a+b)^2") == "(a+b)^2" + + +def test_version_wildcard_dot_is_spoken(): + assert speak_versions("1.x") == "1 dot x" + assert speak_versions("2.*") == "2 dot *" + + +def test_version_operator_survives_the_units_equals_rule(): + """Regression: `expand_units` maps "=" to " equals ", which used to split + ">=" into a silent ">" plus "equals", inverting the meaning.""" + assert normalize_for_speech(">=1.2.3", markdown=False) == "at least 1 point 2 point 3." + + +def test_version_normalizes_end_to_end(): + actual = normalize_for_speech("Upgrade to ^1.2.3-rc.1 today.", markdown=False) + assert actual == "Upgrade to compatible with 1 point 2 point 3, rc 1 today." + + +def test_version_leaves_line_ranges_and_dates_alone(): + assert speak_versions("cli.py:100-118") == "cli.py:100-118" + assert speak_versions("released 2026-09-08") == "released 2026-09-08" + + +def test_version_leaves_two_part_numbers_to_the_decimal_stage(): + """A two-part version is indistinguishable from a decimal, where + digit-by-digit is the correct reading. Deliberately untouched.""" + assert normalize_for_speech("770.72", markdown=False) == "770 point 7 2." + assert normalize_for_speech("3.11", markdown=False) == "3 point 1 1." + + # --- file & path references ------------------------------------------------