Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,9 @@ stackvox speak --normalize --file post.md # normalize, then synthesize
stackvox say --normalize "**Shipped** 1,198.9 MPG" # normalize, then send to daemon
```

`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).
`--normalize` is off by default on `speak`/`say`, so notification phrases synthesize exactly as before. The tuning flags mirror the library defaults (all on except emoji-strip): `--no-markdown`, `--no-dev-terms`, `--no-expand-units`, `--no-expand-numbers`, `--no-pauses`, `--tables {drop,csv}`, `--strip-emoji`, `--no-terminal-stops`, `--locale`, and a `--pronunciations FILE` (a JSON `{"written": "spoken"}` map, applied whole-word and case-insensitively).

**Dev-output handling** (on by default; `--no-dev-terms` to disable) covers two things espeak reads badly: acronyms it says as words (`CLI` → "C L I", `AWS`, `URI`, `IAM`, …) and file-line references, which it voices literally as "dot py colon". A `file.ext:line` ref is re-spoken lead-with-the-line: `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". Times, ratios, and versions (`12:30`, `3:1`, `1.2.3`) are left alone.

Bash completion:

Expand Down
48 changes: 48 additions & 0 deletions stackvox/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@
"strip_thousands_separators",
"versions_to_words",
"decimals_to_words",
"speak_file_refs",
"expand_units",
"apply_pronunciations",
"shape_pauses",
Expand Down Expand Up @@ -110,6 +111,46 @@ def decimals_to_words(text: str) -> str:
)


# --------------------------------------------------------------------------- #
# File & line 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 <file>" (how a person actually says it) and
# soften the path: drop directories to the basename, and voice the dotted name
# word by word.

_FILE_REF = re.compile(
r"(?<!\w)"
r"(?:[\w.-]*/)*" # optional directory segments (dropped — "src/" reads as "slash")
r"([A-Za-z0-9_-]+(?:\.[A-Za-z][\w-]*)+)" # filename with >=1 letter-initial extension
r":(\d+)(?:-(\d+))?(?::(\d+))?" # :line, optional -end (range) or :column
r"(?!\w)"
)


def speak_file_refs(text: str) -> str:
"""Turn ``path/file.ext:line`` refs into spoken "line N of file ext".

``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 —
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)
if end:
location = f"lines {start} to {end}"
else:
location = f"line {start}" + (f", column {column}" if column else "")
return f"{location} of {basename}"

return _FILE_REF.sub(repl, text)


# --------------------------------------------------------------------------- #
# Pronunciations #
# --------------------------------------------------------------------------- #
Expand Down Expand Up @@ -290,11 +331,17 @@ def _shape_paragraph(
text: str,
*,
pronunciations: dict[str, str] | None,
dev_terms_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:
text = speak_file_refs(text)
if expand_numbers_flag:
text = strip_thousands_separators(text)
if pauses_flag:
Expand Down Expand Up @@ -345,6 +392,7 @@ def normalize_for_speech(
shaped = _shape_paragraph(
para,
pronunciations=effective_pronunciations,
dev_terms_flag=dev_terms,
expand_units_flag=expand_units_flag,
expand_numbers_flag=expand_numbers_flag,
pauses_flag=pauses,
Expand Down
39 changes: 39 additions & 0 deletions tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
markdown_to_paragraphs,
normalize_for_speech,
shape_pauses,
speak_file_refs,
strip_emoji,
strip_thousands_separators,
versions_to_words,
Expand Down Expand Up @@ -54,6 +55,44 @@ def test_semver_normalizes_end_to_end():
assert "0 point 7.0" not in out


# --- file & line references ------------------------------------------------


def test_file_ref_leads_with_line_then_file():
assert speak_file_refs("unifiedAPIv2.service.ts:666") == "line 666 of unifiedAPIv2 service 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_line_range():
assert speak_file_refs("cli.py:100-118") == "lines 100 to 118 of cli py"


def test_file_ref_line_and_column():
assert speak_file_refs("foo.ts:666:10") == "line 666, column 10 of foo ts"


def test_file_ref_leaves_times_ratios_verses_untouched():
text = "Meet at 12:30, the ratio was 3:1, see John 3:16."
assert speak_file_refs(text) == text


def test_file_ref_leaves_dotted_versions_untouched():
assert speak_file_refs("bump to 1.2.3 and 0.7.0") == "bump to 1.2.3 and 0.7.0"


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."


def test_file_ref_disabled_with_dev_terms():
out = normalize_for_speech("See engine.py:42.", markdown=False, dev_terms=False)
assert out == "See engine.py:42."


# --- units -----------------------------------------------------------------


Expand Down
Loading