diff --git a/README.md b/README.md index 4e6da85..10031b9 100644 --- a/README.md +++ b/README.md @@ -54,8 +54,23 @@ stackvox welcome # multilingual welcome (6 languages) stackvox voices # list all voice ids echo "from a pipe" | stackvox # piped stdin works for speak/say stackvox speak --file message.txt # read a whole file +stackvox paths # print resolved cache / socket / pid paths +stackvox config # print resolved default voice/speed/lang ``` +### Speech normalization + +`normalize_for_speech` (Markdown → speakable prose: strip formatting, expand units/numbers, shape pauses, apply a pronunciation dictionary) is available from the shell — no Python import required: + +```bash +stackvox normalize --file post.md # print normalized text (pipe / debug) +stackvox normalize --tables csv --strip-emoji < response.md +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). + Bash completion: ```bash diff --git a/stackvox/cli.py b/stackvox/cli.py index 408b9e7..385f698 100644 --- a/stackvox/cli.py +++ b/stackvox/cli.py @@ -10,8 +10,9 @@ import soundfile as sf -from stackvox import config, daemon, updates +from stackvox import config, daemon, paths, updates from stackvox.engine import Stackvox +from stackvox.text import normalize_for_speech def _configure_logging() -> None: @@ -28,8 +29,11 @@ def _configure_logging() -> None: "status", "say", "speak", + "normalize", "voices", "welcome", + "paths", + "config", "completion", "install-helper", } @@ -47,12 +51,12 @@ def _configure_logging() -> None: subcommand="${COMP_WORDS[1]:-}" if [[ ${COMP_CWORD} -eq 1 ]]; then - COMPREPLY=( $(compgen -W "speak say serve stop status voices welcome completion install-helper" -- "$cur") ) + COMPREPLY=( $(compgen -W "speak say normalize serve stop status voices welcome paths config completion install-helper" -- "$cur") ) return 0 fi case "$prev" in - --file|--out) + --file|--out|--pronunciations) COMPREPLY=( $(compgen -f -- "$cur") ) return 0 ;; @@ -68,14 +72,27 @@ def _configure_logging() -> None: COMPREPLY=( $(compgen -W "en-us en-gb fr-fr it hi pt-br es ja zh" -- "$cur") ) return 0 ;; + --tables) + COMPREPLY=( $(compgen -W "drop csv" -- "$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" + case "$subcommand" in speak) - COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --help" -- "$cur") ) + COMPREPLY=( $(compgen -W "--voice --speed --lang --file --out --normalize $norm_flags --help" -- "$cur") ) ;; say) - COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --help" -- "$cur") ) + COMPREPLY=( $(compgen -W "--voice --speed --lang --file --fallback-say --normalize $norm_flags --help" -- "$cur") ) + ;; + normalize) + COMPREPLY=( $(compgen -W "--file $norm_flags --help" -- "$cur") ) ;; serve) COMPREPLY=( $(compgen -W "--voice --speed --lang --help" -- "$cur") ) @@ -115,6 +132,7 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP p_speak.add_argument("text", nargs="?") p_speak.add_argument("--file", type=Path) p_speak.add_argument("--out", type=Path, help="Write wav instead of playing") + _add_normalize_args(p_speak, with_switch=True) p_say = sub.add_parser("say", help="Send text to daemon (fast; fails if daemon not running)") _add_voice_args(p_say, defaults) @@ -123,6 +141,15 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP p_say.add_argument( "--fallback-say", action="store_true", help="Shell out to macOS `say` if daemon unreachable" ) + _add_normalize_args(p_say, with_switch=True) + + p_normalize = sub.add_parser( + "normalize", + help="Print speech-normalized text (Markdown -> speakable prose); no synthesis", + ) + p_normalize.add_argument("text", nargs="?") + p_normalize.add_argument("--file", type=Path) + _add_normalize_args(p_normalize, with_switch=False) p_serve = sub.add_parser("serve", help="Run the daemon in the foreground") _add_voice_args(p_serve, defaults) @@ -131,6 +158,8 @@ def _build_parser(defaults: config.Defaults | None = None) -> argparse.ArgumentP sub.add_parser("status", help="Print daemon status") sub.add_parser("voices", help="List available voices") sub.add_parser("welcome", help="Play a multilingual welcome message") + sub.add_parser("paths", help="Print resolved cache / socket / pid file paths") + sub.add_parser("config", help="Print resolved default voice/speed/lang and config path") p_completion = sub.add_parser("completion", help="Print a shell completion script") p_completion.add_argument("shell", choices=["bash"], help="Shell to generate completion for") @@ -155,6 +184,114 @@ def _add_voice_args(parser: argparse.ArgumentParser, defaults: config.Defaults) parser.add_argument("--lang", default=defaults.lang) +def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -> None: + """Flags mapping one-to-one onto ``stackvox.text.normalize_for_speech``. + + Defaults mirror the library: markdown / units / numbers / pauses / + terminal-stops on, emoji-strip off, tables dropped, ``en-GB`` locale. + + ``with_switch`` adds the ``--normalize`` master toggle used by ``speak`` and + ``say`` (off by default, so notification phrases synth exactly as before). + The ``normalize`` subcommand always normalizes, so it omits the switch. + """ + if with_switch: + parser.add_argument( + "--normalize", + action="store_true", + help="Normalize text for speech before synthesizing (see `stackvox normalize`)", + ) + parser.add_argument( + "--no-markdown", dest="markdown", action="store_false", help="Treat input as plain text, not Markdown" + ) + parser.add_argument( + "--pronunciations", + type=Path, + help="JSON file mapping written -> spoken forms (whole-word, case-insensitive)", + ) + parser.add_argument( + "--no-expand-units", + dest="expand_units", + action="store_false", + help="Leave £/p/kWh/MPG/kg/km and ÷ × = as written", + ) + parser.add_argument( + "--no-expand-numbers", + dest="expand_numbers", + action="store_false", + help="Leave thousands separators and decimals as written", + ) + parser.add_argument( + "--no-pauses", + dest="pauses", + action="store_false", + help="Do not reshape dashes / parentheses into pauses", + ) + parser.add_argument( + "--tables", + choices=["drop", "csv"], + default="drop", + help="How Markdown tables are voiced (default: drop)", + ) + parser.add_argument( + "--strip-emoji", dest="strip_emoji", action="store_true", help="Remove emoji before speaking" + ) + parser.add_argument( + "--no-terminal-stops", + dest="terminal_stops", + action="store_false", + help="Do not add terminal punctuation so a pause lands between lines", + ) + parser.add_argument("--locale", default="en-GB", help="Locale for unit/currency rules (default: en-GB)") + + +def _load_pronunciations(path: Path | None) -> dict[str, str] | None: + """Load a ``{written: spoken}`` map from a JSON file. + + Returns ``None`` when no path is given. Raises ``ValueError`` if the file + is not a flat table of string -> string entries, so a mistyped dict fails + loudly rather than silently voicing the wrong thing. + """ + if path is None: + return None + import json + + data = json.loads(path.expanduser().read_text(encoding="utf-8")) + if not isinstance(data, dict) or not all( + isinstance(key, str) and isinstance(value, str) for key, value in data.items() + ): + raise ValueError(f"{path}: expected a flat JSON object of string -> string entries") + return data + + +def _normalize_kwargs(args: argparse.Namespace) -> dict: + """Collect the normalize_for_speech keyword arguments from parsed flags.""" + return { + "markdown": args.markdown, + "pronunciations": _load_pronunciations(args.pronunciations), + "expand_units": args.expand_units, + "expand_numbers": args.expand_numbers, + "pauses": args.pauses, + "tables": args.tables, + "strip_emoji": args.strip_emoji, + "terminal_stops": args.terminal_stops, + "locale": args.locale, + } + + +def _resolve_normalized(text: str, args: argparse.Namespace) -> str | None: + """Normalize ``text`` for speech, or print a clean error and return None. + + A missing or malformed ``--pronunciations`` file surfaces here as an + ``OSError`` / ``ValueError`` (``json.JSONDecodeError`` is a ``ValueError``); + turn it into a one-line ``[stackvox] …`` message rather than a traceback. + """ + try: + return normalize_for_speech(text, **_normalize_kwargs(args)) + except (OSError, ValueError) as exc: + print(f"[stackvox] {exc}", file=sys.stderr) + return None + + def _read_text(args: argparse.Namespace) -> str | None: """Resolve the text to speak from --file, the positional, or piped stdin. @@ -177,6 +314,10 @@ def _cmd_speak(args: argparse.Namespace) -> int: if not text or not text.strip(): print("Error: provide text or --file", file=sys.stderr) return 1 + if getattr(args, "normalize", False): + text = _resolve_normalized(text, args) + if text is None: + return 1 tts = Stackvox(voice=args.voice, speed=args.speed, lang=args.lang) if args.out: samples, sr = tts.synthesize(text) @@ -192,6 +333,10 @@ def _cmd_say(args: argparse.Namespace) -> int: if not text or not text.strip(): print("Error: provide text or --file", file=sys.stderr) return 1 + if getattr(args, "normalize", False): + text = _resolve_normalized(text, args) + if text is None: + return 1 ok, resp = daemon.say(text, voice=args.voice, speed=args.speed, lang=args.lang) if ok: return 0 @@ -206,6 +351,18 @@ def _cmd_say(args: argparse.Namespace) -> int: return 2 +def _cmd_normalize(args: argparse.Namespace) -> int: + text = _read_text(args) + if not text or not text.strip(): + print("Error: provide text or --file", file=sys.stderr) + return 1 + normalized = _resolve_normalized(text, args) + if normalized is None: + return 1 + print(normalized) + return 0 + + def _cmd_serve(args: argparse.Namespace) -> int: try: daemon.serve(voice=args.voice, speed=args.speed, lang=args.lang) @@ -248,6 +405,24 @@ def _cmd_voices(args: argparse.Namespace) -> int: return 0 +def _cmd_paths(_: argparse.Namespace) -> int: + print(f"cache_dir: {paths.cache_dir()}") + print(f"socket_path: {paths.socket_path()}") + print(f"pid_path: {paths.pid_path()}") + return 0 + + +def _cmd_config(_: argparse.Namespace) -> int: + path = config.config_path() + present = "" if path.is_file() else " (not present; built-in defaults apply)" + defaults = config.load_defaults() + print(f"config_path: {path}{present}") + print(f"voice: {defaults.voice}") + print(f"speed: {defaults.speed}") + print(f"lang: {defaults.lang}") + return 0 + + def _cmd_welcome(_: argparse.Namespace) -> int: tts = Stackvox() tts.speak_sequence([{"text": text, "voice": voice, "lang": lang} for voice, lang, text in WELCOME_LINES]) @@ -336,11 +511,14 @@ def main() -> int: handlers = { "speak": _cmd_speak, "say": _cmd_say, + "normalize": _cmd_normalize, "serve": _cmd_serve, "stop": _cmd_stop, "status": _cmd_status, "voices": _cmd_voices, "welcome": _cmd_welcome, + "paths": _cmd_paths, + "config": _cmd_config, "completion": _cmd_completion, "install-helper": _cmd_install_helper, } diff --git a/tests/test_cli.py b/tests/test_cli.py index af7c923..b843a1f 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -121,6 +121,25 @@ def _ns(**kwargs): return argparse.Namespace(**kwargs) +def _norm_ns(text=None, file=None, **overrides): + """Namespace carrying the normalize flags at their library defaults.""" + base = dict( + text=text, + file=file, + markdown=True, + pronunciations=None, + expand_units=True, + expand_numbers=True, + pauses=True, + tables="drop", + strip_emoji=False, + terminal_stops=True, + locale="en-GB", + ) + base.update(overrides) + return _ns(**base) + + class TestCmdSpeak: def test_with_text_calls_engine_speak(self, fake_stackvox): rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="hello", out=None)) @@ -149,6 +168,18 @@ def test_blank_input_returns_error(self, fake_stackvox, capsys): assert "provide text" in capsys.readouterr().err fake_stackvox.return_value.speak.assert_not_called() + def test_normalize_transforms_text_before_engine(self, fake_stackvox): + args = _norm_ns(text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", out=None, normalize=True) + rc = cli._cmd_speak(args) + assert rc == 0 + fake_stackvox.return_value.speak.assert_called_once_with("Heading.") + + def test_no_normalize_leaves_text_untouched(self, fake_stackvox): + # `normalize` absent from the namespace mirrors the flag being off. + rc = cli._cmd_speak(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="# Heading", out=None)) + assert rc == 0 + fake_stackvox.return_value.speak.assert_called_once_with("# Heading") + class TestCmdSay: def test_returns_zero_when_daemon_accepts(self, mocker): @@ -182,6 +213,15 @@ def test_blank_input_returns_one(self, capsys): rc = cli._cmd_say(_ns(voice="af_sarah", speed=1.0, lang="en-us", text="", fallback_say=False)) assert rc == 1 + def test_normalize_transforms_text_before_daemon(self, mocker): + say = mocker.patch.object(cli.daemon, "say", return_value=(True, "ok")) + args = _norm_ns( + text="# Heading", voice="af_sarah", speed=1.0, lang="en-us", fallback_say=False, normalize=True + ) + rc = cli._cmd_say(args) + assert rc == 0 + assert say.call_args.args[0] == "Heading." + class TestCmdServe: def test_propagates_serve_args(self, mocker): @@ -353,3 +393,112 @@ def test_no_warning_when_prefix_on_path(self, tmp_path, mocker, capsys): mocker.patch.dict("os.environ", {"PATH": f"{prefix}:/usr/bin"}, clear=False) cli._cmd_install_helper(_ns(prefix=prefix)) assert "not on your PATH" not in capsys.readouterr().err + + +class TestCmdNormalize: + def test_markdown_reduces_to_prose_with_terminal_stops(self, capsys): + rc = cli._cmd_normalize(_norm_ns(text="# Heading\n\nBody")) + assert rc == 0 + assert capsys.readouterr().out.splitlines() == ["Heading.", "Body."] + + def test_number_and_decimal_expansion(self, capsys): + rc = cli._cmd_normalize(_norm_ns(text="It was 1,198.9 total")) + assert rc == 0 + assert capsys.readouterr().out.strip() == "It was 1198 point 9 total." + + def test_pronunciations_file_is_applied(self, capsys, tmp_path): + pron = tmp_path / "dict.json" + pron.write_text('{"Redis": "ree-diss"}', encoding="utf-8") + rc = cli._cmd_normalize(_norm_ns(text="Redis is fast", pronunciations=pron)) + assert rc == 0 + assert capsys.readouterr().out.strip() == "ree-diss is fast." + + def test_blank_input_returns_one(self, capsys): + rc = cli._cmd_normalize(_norm_ns(text=" ")) + assert rc == 1 + assert "provide text" in capsys.readouterr().err + + def test_bad_pronunciations_file_errors_cleanly(self, capsys, tmp_path): + bad = tmp_path / "bad.json" + bad.write_text('["not", "a", "map"]', encoding="utf-8") + rc = cli._cmd_normalize(_norm_ns(text="hi", pronunciations=bad)) + assert rc == 1 + assert "string -> string" in capsys.readouterr().err + + +class TestLoadPronunciations: + def test_none_path_returns_none(self): + assert cli._load_pronunciations(None) is None + + def test_valid_json_returns_mapping(self, tmp_path): + path = tmp_path / "d.json" + path.write_text('{"CLI": "see ell eye"}', encoding="utf-8") + assert cli._load_pronunciations(path) == {"CLI": "see ell eye"} + + def test_non_object_json_raises(self, tmp_path): + path = tmp_path / "d.json" + path.write_text('["not", "a", "map"]', encoding="utf-8") + with pytest.raises(ValueError, match="string -> string"): + cli._load_pronunciations(path) + + def test_non_string_values_raise(self, tmp_path): + path = tmp_path / "d.json" + path.write_text('{"n": 3}', encoding="utf-8") + with pytest.raises(ValueError, match="string -> string"): + cli._load_pronunciations(path) + + +class TestCmdPaths: + def test_prints_the_three_paths(self, capsys): + rc = cli._cmd_paths(_ns()) + assert rc == 0 + out = capsys.readouterr().out + assert "cache_dir:" in out + assert "socket_path:" in out + assert "pid_path:" in out + + +class TestCmdConfig: + def test_prints_resolved_defaults_and_path(self, mocker, capsys, tmp_path): + cfg = tmp_path / "config.toml" # absent → "not present" note + mocker.patch.object(cli.config, "config_path", return_value=cfg) + mocker.patch.object( + cli.config, + "load_defaults", + return_value=cli.config.Defaults(voice="bf_emma", speed=1.1, lang="en-gb"), + ) + rc = cli._cmd_config(_ns()) + assert rc == 0 + out = capsys.readouterr().out + assert "not present" in out + assert "bf_emma" in out + assert "1.1" in out + assert "en-gb" in out + + +class TestNormalizeParsing: + def test_normalize_subcommand_routes(self, mocker): + norm = mocker.patch.object(cli, "_cmd_normalize", return_value=0) + mocker.patch.object(cli.sys, "argv", ["stackvox", "normalize", "--tables", "csv", "hi"]) + assert cli.main() == 0 + args = norm.call_args.args[0] + assert args.cmd == "normalize" + assert args.tables == "csv" + assert args.text == "hi" + + def test_speak_normalize_switch_parses(self, mocker): + speak = mocker.patch.object(cli, "_cmd_speak", return_value=0) + mocker.patch.object( + cli.sys, "argv", ["stackvox", "speak", "--normalize", "--strip-emoji", "--no-pauses", "hi"] + ) + assert cli.main() == 0 + args = speak.call_args.args[0] + assert args.normalize is True + assert args.strip_emoji is True + assert args.pauses is False + + def test_paths_and_config_route(self, mocker): + paths_handler = mocker.patch.object(cli, "_cmd_paths", return_value=0) + mocker.patch.object(cli.sys, "argv", ["stackvox", "paths"]) + assert cli.main() == 0 + assert paths_handler.called