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,10 +69,12 @@ 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-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).
`--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}`, `--code-blocks {drop,placeholder}`, `--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.

Fenced code blocks are dropped by default. `--code-blocks placeholder` instead speaks a short stand-in so a skipped block doesn't sound disjointed (ChatGPT-style) — set the wording with `--code-placeholder` (e.g. `--code-blocks placeholder --code-placeholder "you can see the code in the chat history"`); consecutive blocks collapse to one.

Bash completion:

```bash
Expand Down
21 changes: 20 additions & 1 deletion stackvox/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,17 @@ def _configure_logging() -> None:
COMPREPLY=( $(compgen -W "drop csv" -- "$cur") )
return 0
;;
--code-blocks)
COMPREPLY=( $(compgen -W "drop placeholder" -- "$cur") )
return 0
;;
--locale)
COMPREPLY=( $(compgen -W "en-GB" -- "$cur") )
return 0
;;
esac

local norm_flags="--no-markdown --pronunciations --no-expand-units --no-expand-numbers --no-pauses --tables --strip-emoji --no-terminal-stops --locale"
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"

case "$subcommand" in
speak)
Expand Down Expand Up @@ -240,6 +244,19 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
default="drop",
help="How Markdown tables are voiced (default: drop)",
)
parser.add_argument(
"--code-blocks",
dest="code_blocks",
choices=["drop", "placeholder"],
default="drop",
help="How fenced code blocks are voiced (default: drop)",
)
parser.add_argument(
"--code-placeholder",
dest="code_placeholder",
default="Code block.",
help='Spoken stand-in for a code block when --code-blocks placeholder (default: "Code block.")',
)
parser.add_argument(
"--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking"
)
Expand Down Expand Up @@ -281,6 +298,8 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
"expand_numbers": args.expand_numbers,
"pauses": args.pauses,
"tables": args.tables,
"code_blocks": args.code_blocks,
"code_placeholder": args.code_placeholder,
"strip_emoji": args.strip_emoji,
"terminal_stops": args.terminal_stops,
"locale": args.locale,
Expand Down
37 changes: 33 additions & 4 deletions stackvox/text.py
Original file line number Diff line number Diff line change
Expand Up @@ -230,11 +230,24 @@ def _strip_md_inline(line: str) -> str:
return line


def markdown_to_paragraphs(text: str, *, tables: str = "drop", strip_emoji_flag: bool = False) -> list[str]:
def markdown_to_paragraphs(
text: str,
*,
tables: str = "drop",
strip_emoji_flag: bool = False,
code_blocks: str = "drop",
code_placeholder: str = "",
) -> list[str]:
"""Reduce Markdown to a list of speakable paragraphs. Headings and list
items become their own paragraphs (so each gets its own pause). Tables are
dropped or rendered comma-separated per ``tables``."""
text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", "\n", text) # fenced code
dropped or rendered comma-separated per ``tables``. Fenced code blocks are
dropped, or (``code_blocks="placeholder"``) replaced with a spoken
``code_placeholder`` so a silently-skipped block doesn't sound disjointed."""
speak_code = code_blocks == "placeholder" and bool(code_placeholder)
# A function replacement keeps the placeholder literal (no backref parsing);
# blank lines around it make it a standalone paragraph.
fenced = f"\n\n{code_placeholder}\n\n" if speak_code else "\n"
text = re.sub(r"(?ms)^[ \t]*(```|~~~).*?^[ \t]*\1[ \t]*$", lambda _: fenced, text) # fenced code
text = re.sub(r"```+|~~~+", " ", text) # stray fences
if strip_emoji_flag:
text = strip_emoji(text)
Expand Down Expand Up @@ -319,6 +332,14 @@ def emit_csv_row(cells_source: str) -> None:
flush()

flush()

if speak_code: # collapse runs of adjacent code blocks into one placeholder
collapsed: list[str] = []
for para in paragraphs:
if para == code_placeholder and collapsed and collapsed[-1] == code_placeholder:
continue
collapsed.append(para)
return collapsed
return paragraphs


Expand Down Expand Up @@ -366,6 +387,8 @@ def normalize_for_speech(
expand_numbers: bool = True,
pauses: bool = True,
tables: str = "drop",
code_blocks: str = "drop",
code_placeholder: str = "Code block.",
strip_emoji: bool = False,
terminal_stops: bool = True,
locale: str = DEFAULT_LOCALE,
Expand All @@ -380,7 +403,13 @@ def normalize_for_speech(
effective_pronunciations[written.lower()] = spoken

if markdown:
paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
paragraphs = markdown_to_paragraphs(
text,
tables=tables,
strip_emoji_flag=strip_emoji,
code_blocks=code_blocks,
code_placeholder=code_placeholder,
)
else:
# `strip_emoji` (the bool kwarg) shadows the module function here, so
# reach for the underlying pattern directly.
Expand Down
22 changes: 22 additions & 0 deletions tests/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,8 @@ def _norm_ns(text=None, file=None, **overrides):
expand_numbers=True,
pauses=True,
tables="drop",
code_blocks="drop",
code_placeholder="Code block.",
strip_emoji=False,
terminal_stops=True,
locale="en-GB",
Expand Down Expand Up @@ -524,6 +526,26 @@ def test_paths_and_config_route(self, mocker):
assert cli.main() == 0
assert paths_handler.called

def test_code_block_flags_parse(self, mocker):
norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0)
mocker.patch.object(
cli.sys,
"argv",
[
"stackvox",
"normalize",
"--code-blocks",
"placeholder",
"--code-placeholder",
"see the chat",
"hi",
],
)
assert cli.main() == 0
args = norm.call_args.args[0]
assert args.code_blocks == "placeholder"
assert args.code_placeholder == "see the chat"


class TestCmdCancel:
def test_running_calls_daemon_cancel(self, mocker):
Expand Down
23 changes: 23 additions & 0 deletions tests/test_text.py
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,29 @@ def test_fenced_code_dropped():
assert markdown_to_paragraphs(md) == ["Before.", "After."]


def test_fenced_code_replaced_with_placeholder():
md = "Before.\n\n```py\nprint('hi')\n```\n\nAfter."
actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code")
assert actual == ["Before.", "see the code", "After."]


def test_consecutive_code_blocks_collapse_to_one_placeholder():
md = "```py\na\n```\n\n```py\nb\n```"
actual = markdown_to_paragraphs(md, code_blocks="placeholder", code_placeholder="see the code")
assert actual == ["see the code"]


def test_code_placeholder_spoken_end_to_end():
md = "Here is how:\n\n```py\nx = 1\n```\n\nDone."
out = normalize_for_speech(md, code_blocks="placeholder", code_placeholder="see the chat history")
assert out == "Here is how:\nsee the chat history.\nDone."


def test_code_blocks_dropped_by_default_end_to_end():
md = "Here is how:\n\n```py\nx = 1\n```\n\nDone."
assert normalize_for_speech(md) == "Here is how:\nDone."


def test_tables_dropped_by_default():
md = "| A | B |\n| --- | --- |\n| 1 | 2 |"
assert markdown_to_paragraphs(md) == []
Expand Down
Loading