stackvox 0.7.0__tar.gz → 0.9.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. {stackvox-0.7.0/stackvox.egg-info → stackvox-0.9.0}/PKG-INFO +1 -1
  2. {stackvox-0.7.0 → stackvox-0.9.0}/pyproject.toml +1 -1
  3. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/cli.py +16 -0
  4. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/daemon.py +15 -0
  5. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/engine.py +54 -2
  6. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/text.py +46 -1
  7. {stackvox-0.7.0 → stackvox-0.9.0/stackvox.egg-info}/PKG-INFO +1 -1
  8. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_cli.py +21 -0
  9. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_daemon_protocol.py +16 -0
  10. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_engine.py +28 -7
  11. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_text.py +51 -0
  12. {stackvox-0.7.0 → stackvox-0.9.0}/LICENSE +0 -0
  13. {stackvox-0.7.0 → stackvox-0.9.0}/NOTICE +0 -0
  14. {stackvox-0.7.0 → stackvox-0.9.0}/README.md +0 -0
  15. {stackvox-0.7.0 → stackvox-0.9.0}/setup.cfg +0 -0
  16. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/__init__.py +0 -0
  17. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/__main__.py +0 -0
  18. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/config.py +0 -0
  19. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/data/stackvox-say +0 -0
  20. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/paths.py +0 -0
  21. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/py.typed +0 -0
  22. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox/updates.py +0 -0
  23. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/SOURCES.txt +0 -0
  24. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/dependency_links.txt +0 -0
  25. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/entry_points.txt +0 -0
  26. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/requires.txt +0 -0
  27. {stackvox-0.7.0 → stackvox-0.9.0}/stackvox.egg-info/top_level.txt +0 -0
  28. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_config.py +0 -0
  29. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_daemon.py +0 -0
  30. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_paths.py +0 -0
  31. {stackvox-0.7.0 → stackvox-0.9.0}/tests/test_updates.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "stackvox"
7
- version = "0.7.0"
7
+ version = "0.9.0"
8
8
  description = "Offline TTS library using Kokoro-82M"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.10"
@@ -208,6 +208,12 @@ def _add_normalize_args(parser: argparse.ArgumentParser, *, with_switch: bool) -
208
208
  type=Path,
209
209
  help="JSON file mapping written -> spoken forms (whole-word, case-insensitive)",
210
210
  )
211
+ parser.add_argument(
212
+ "--no-dev-terms",
213
+ dest="dev_terms",
214
+ action="store_false",
215
+ help="Do not spell out dev acronyms espeak mispronounces (CLI, CI, IDE, AWS, URI, IAM, ...)",
216
+ )
211
217
  parser.add_argument(
212
218
  "--no-expand-units",
213
219
  dest="expand_units",
@@ -268,6 +274,7 @@ def _normalize_kwargs(args: argparse.Namespace) -> dict:
268
274
  return {
269
275
  "markdown": args.markdown,
270
276
  "pronunciations": _load_pronunciations(args.pronunciations),
277
+ "dev_terms": args.dev_terms,
271
278
  "expand_units": args.expand_units,
272
279
  "expand_numbers": args.expand_numbers,
273
280
  "pauses": args.pauses,
@@ -382,9 +389,18 @@ def _cmd_stop(_: argparse.Namespace) -> int:
382
389
 
383
390
 
384
391
  def _cmd_status(_: argparse.Namespace) -> int:
392
+ installed = updates._current_version()
385
393
  if daemon.is_running():
386
394
  print(f"running (pid {daemon.PID_PATH.read_text().strip()}) on {daemon.SOCKET_PATH}")
387
395
  rc = 0
396
+ # The running daemon can lag the installed package if it wasn't restarted
397
+ # after an upgrade — surface that skew instead of trusting it blindly.
398
+ got, running_version = daemon.version()
399
+ if got and running_version != installed:
400
+ print(
401
+ f"daemon running {running_version}, but {installed} is installed "
402
+ f"— restart the daemon (stackvox stop; stackvox serve) to pick it up"
403
+ )
388
404
  else:
389
405
  print("stopped")
390
406
  rc = 1
@@ -222,6 +222,10 @@ class _Handler(socketserver.StreamRequestHandler):
222
222
  self.wfile.write(b"ok\n")
223
223
  return
224
224
 
225
+ if command == "version":
226
+ self.wfile.write(f"{updates._current_version()}\n".encode())
227
+ return
228
+
225
229
  if command == "stop":
226
230
  self.wfile.write(b"ok\n")
227
231
  threading.Thread(target=self.server.shutdown, daemon=True).start()
@@ -343,3 +347,14 @@ def stop() -> tuple[bool, str]:
343
347
 
344
348
  def ping() -> tuple[bool, str]:
345
349
  return send({"command": "ping"}, timeout=PING_TIMEOUT_SECONDS)
350
+
351
+
352
+ def version(timeout: float = PING_TIMEOUT_SECONDS) -> tuple[bool, str]:
353
+ """(got_version, version_or_error) reported by the running daemon.
354
+
355
+ The running daemon can differ from the installed package if it wasn't
356
+ restarted after an upgrade, so this reports what is actually serving — not
357
+ what's on disk. A version reply starts with a digit; error strings don't.
358
+ """
359
+ _, resp = send({"command": "version"}, timeout=timeout)
360
+ return resp[:1].isdigit(), resp
@@ -22,6 +22,12 @@ DEFAULT_VOICE = "af_sarah"
22
22
  DEFAULT_SPEED = 1.0
23
23
  DEFAULT_LANG = "en-us"
24
24
 
25
+ # Streaming playback smoothing: play a touch of silence before the first word so
26
+ # output-device warm-up (notably a Bluetooth codec/profile switch) lands during
27
+ # silence, and ramp the opening samples up from zero to avoid an onset click.
28
+ _PRIME_SILENCE_SECONDS = 0.12
29
+ _FADE_IN_SECONDS = 0.008
30
+
25
31
  _MODEL_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/kokoro-v1.0.onnx"
26
32
  _VOICES_URL = "https://github.com/thewh1teagle/kokoro-onnx/releases/download/model-files-v1.0/voices-v1.0.bin"
27
33
 
@@ -82,6 +88,16 @@ def _split_sentences(text: str) -> list[str]:
82
88
  return [part.strip() for part in _SENTENCE_BOUNDARY.split(text.strip()) if part.strip()]
83
89
 
84
90
 
91
+ def _fade_in(samples: np.ndarray, sample_rate: int) -> np.ndarray:
92
+ """Ramp the opening samples up from zero so playback doesn't begin with a click."""
93
+ ramp_len = min(len(samples), int(sample_rate * _FADE_IN_SECONDS))
94
+ if ramp_len <= 0:
95
+ return samples
96
+ faded = np.array(samples, dtype="float32", copy=True)
97
+ faded[:ramp_len] *= np.linspace(0.0, 1.0, ramp_len, dtype="float32")
98
+ return faded
99
+
100
+
85
101
  class Stackvox:
86
102
  """Reusable TTS engine. Load the model once, speak many times.
87
103
 
@@ -104,6 +120,8 @@ class Stackvox:
104
120
  self._kokoro = Kokoro(str(model_path), str(voices_path))
105
121
  self._stop_event = threading.Event()
106
122
  self._play_thread: threading.Thread | None = None
123
+ self._stream: sd.OutputStream | None = None
124
+ self._stream_lock = threading.Lock()
107
125
 
108
126
  def synthesize(
109
127
  self,
@@ -210,6 +228,7 @@ class Stackvox:
210
228
  producer = threading.Thread(target=_produce, daemon=True, name="stackvox-synth")
211
229
  producer.start()
212
230
  error: Exception | None = None
231
+ stream: sd.OutputStream | None = None
213
232
  try:
214
233
  while not stop_event.is_set():
215
234
  item = chunks.get()
@@ -219,9 +238,36 @@ class Stackvox:
219
238
  error = item
220
239
  break
221
240
  samples, sample_rate = item
222
- sd.play(samples, sample_rate)
223
- sd.wait()
241
+ samples = np.ascontiguousarray(samples, dtype="float32")
242
+ if stream is None:
243
+ # One stream for the whole utterance: writing chunks into it is
244
+ # gapless and avoids the per-sentence open/close that crackles at
245
+ # the start. Prime with silence so device warm-up (e.g. a
246
+ # Bluetooth codec switch) lands before the first word, and fade
247
+ # the opening samples in to avoid an onset click.
248
+ stream = sd.OutputStream(samplerate=sample_rate, channels=1, dtype="float32")
249
+ stream.start()
250
+ with self._stream_lock:
251
+ self._stream = stream
252
+ prime = int(sample_rate * _PRIME_SILENCE_SECONDS)
253
+ if prime > 0:
254
+ stream.write(np.zeros(prime, dtype="float32"))
255
+ samples = _fade_in(samples, sample_rate)
256
+ try:
257
+ stream.write(samples)
258
+ except Exception:
259
+ if stop_event.is_set():
260
+ break # aborted by stop(); expected
261
+ raise
224
262
  finally:
263
+ with self._stream_lock:
264
+ self._stream = None
265
+ if stream is not None:
266
+ try:
267
+ stream.stop()
268
+ stream.close()
269
+ except Exception:
270
+ logger.debug("stream teardown failed", exc_info=True)
225
271
  # Stop the producer and keep draining so it can never stay parked on
226
272
  # a full queue — that's what guarantees the synth thread always exits.
227
273
  stop_event.set()
@@ -236,6 +282,12 @@ class Stackvox:
236
282
  def stop(self) -> None:
237
283
  """Stop any in-progress playback started with blocking=False."""
238
284
  self._stop_event.set()
285
+ with self._stream_lock:
286
+ if self._stream is not None:
287
+ try:
288
+ self._stream.abort()
289
+ except Exception:
290
+ logger.debug("stream abort failed", exc_info=True)
239
291
  sd.stop()
240
292
 
241
293
  def voices(self) -> list[str]:
@@ -18,6 +18,7 @@ __all__ = [
18
18
  "markdown_to_paragraphs",
19
19
  "strip_emoji",
20
20
  "strip_thousands_separators",
21
+ "versions_to_words",
21
22
  "decimals_to_words",
22
23
  "expand_units",
23
24
  "apply_pronunciations",
@@ -84,6 +85,21 @@ def strip_thousands_separators(text: str) -> str:
84
85
  return re.sub(r"(?<=\d),(?=\d)", "", text)
85
86
 
86
87
 
88
+ _VERSION = re.compile(r"(?<!\d)\d+(?:\.\d+){2,}(?!\d)")
89
+
90
+
91
+ def versions_to_words(text: str) -> str:
92
+ """1.2.3 -> "1 point 2 point 3"; 0.7.0 -> "0 point 7 point 0".
93
+
94
+ A dotted version string has more than one decimal point, which
95
+ `decimals_to_words` can't handle — it splits only the first, leaving a stray
96
+ full stop mid-number ("0.7.0" -> "0 point 7.0"). Run this before it. Two-part
97
+ numbers are left untouched so ordinary decimals still read digit-by-digit,
98
+ and a trailing sentence stop (``upgrade to 1.2.3.``) is preserved.
99
+ """
100
+ return _VERSION.sub(lambda m: m.group(0).replace(".", " point "), text)
101
+
102
+
87
103
  def decimals_to_words(text: str) -> str:
88
104
  """1198.9 -> "1198 point 9"; 770.72 -> "770 point 7 2". Removes the bare
89
105
  "." between digits, which TTS can otherwise read as a full stop."""
@@ -106,6 +122,28 @@ def apply_pronunciations(text: str, mapping: dict[str, str] | None) -> str:
106
122
  return text
107
123
 
108
124
 
125
+ # Dev acronyms/terms espeak mispronounces — it reads them as a word ("CLI" ->
126
+ # "kligh", "AWS" -> "awz", "URI" -> "yuri") instead of spelling them out.
127
+ # Applied by default (dev_terms=True), whole-word and case-insensitive, so
128
+ # lowercase "cli" is fixed too. Compound keys (ci/cd) precede their parts (ci)
129
+ # so the specific form wins. Only terms espeak gets WRONG are here — API, URL,
130
+ # JSON, YAML, HTTP, CRUD, nginx, etc. already voice correctly and are left alone.
131
+ _DEV_PRONUNCIATIONS: dict[str, str] = {
132
+ "ci/cd": "C I C D",
133
+ "cli": "C L I",
134
+ "ci": "C I",
135
+ "ide": "I D E",
136
+ "aws": "A W S",
137
+ "uri": "U R I",
138
+ "iam": "I A M",
139
+ "saas": "sass",
140
+ "paas": "pass",
141
+ "tui": "T U I",
142
+ "postgresql": "postgres",
143
+ "kubectl": "kube control",
144
+ }
145
+
146
+
109
147
  # --------------------------------------------------------------------------- #
110
148
  # Pauses #
111
149
  # --------------------------------------------------------------------------- #
@@ -265,6 +303,7 @@ def _shape_paragraph(
265
303
  if expand_units_flag: # units BEFORE decimals (see note above)
266
304
  text = expand_units(text, locale)
267
305
  if expand_numbers_flag:
306
+ text = versions_to_words(text) # multi-dot versions BEFORE the decimal split
268
307
  text = decimals_to_words(text)
269
308
  return re.sub(r"[ \t]{2,}", " ", text).strip()
270
309
 
@@ -274,6 +313,7 @@ def normalize_for_speech(
274
313
  *,
275
314
  markdown: bool = True,
276
315
  pronunciations: dict[str, str] | None = None,
316
+ dev_terms: bool = True,
277
317
  expand_units: bool = True,
278
318
  expand_numbers: bool = True,
279
319
  pauses: bool = True,
@@ -285,6 +325,11 @@ def normalize_for_speech(
285
325
  """Normalize ``text`` into speakable prose. Returns paragraphs joined by
286
326
  newlines. See ``docs/speech-normalization.md`` for the full contract."""
287
327
  expand_units_flag, expand_numbers_flag = expand_units, expand_numbers
328
+ # Built-in dev-term fixes first; caller-supplied pronunciations override them
329
+ # (keyed case-insensitively, so a caller's "CLI" beats the default "cli").
330
+ effective_pronunciations: dict[str, str] = dict(_DEV_PRONUNCIATIONS) if dev_terms else {}
331
+ for written, spoken in (pronunciations or {}).items():
332
+ effective_pronunciations[written.lower()] = spoken
288
333
 
289
334
  if markdown:
290
335
  paragraphs = markdown_to_paragraphs(text, tables=tables, strip_emoji_flag=strip_emoji)
@@ -298,7 +343,7 @@ def normalize_for_speech(
298
343
  for para in paragraphs:
299
344
  shaped = _shape_paragraph(
300
345
  para,
301
- pronunciations=pronunciations,
346
+ pronunciations=effective_pronunciations,
302
347
  expand_units_flag=expand_units_flag,
303
348
  expand_numbers_flag=expand_numbers_flag,
304
349
  pauses_flag=pauses,
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stackvox
3
- Version: 0.7.0
3
+ Version: 0.9.0
4
4
  Summary: Offline TTS library using Kokoro-82M
5
5
  Author-email: "StackOne Technologies Ltd." <support@stackone.com>
6
6
  License-Expression: Apache-2.0
@@ -128,6 +128,7 @@ def _norm_ns(text=None, file=None, **overrides):
128
128
  file=file,
129
129
  markdown=True,
130
130
  pronunciations=None,
131
+ dev_terms=True,
131
132
  expand_units=True,
132
133
  expand_numbers=True,
133
134
  pauses=True,
@@ -260,6 +261,9 @@ class TestCmdStatus:
260
261
  pid_path.read_text.return_value = "12345\n"
261
262
  mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
262
263
  mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
264
+ # status now queries the running daemon's version; stub it so the test
265
+ # doesn't reach a real socket.
266
+ mocker.patch.object(cli.daemon, "version", return_value=(False, "n/a"))
263
267
 
264
268
  rc = cli._cmd_status(_ns())
265
269
 
@@ -268,6 +272,23 @@ class TestCmdStatus:
268
272
  assert "running" in out
269
273
  assert "12345" in out
270
274
 
275
+ def test_running_flags_daemon_version_skew(self, mocker, capsys):
276
+ mocker.patch.object(cli.daemon, "is_running", return_value=True)
277
+ pid_path = mocker.MagicMock()
278
+ pid_path.read_text.return_value = "1\n"
279
+ mocker.patch.object(cli.daemon, "PID_PATH", pid_path)
280
+ mocker.patch.object(cli.daemon, "SOCKET_PATH", "/tmp/x.sock")
281
+ mocker.patch.object(cli.daemon, "version", return_value=(True, "0.5.0"))
282
+ mocker.patch.object(cli.updates, "_current_version", return_value="0.8.0")
283
+ mocker.patch.object(cli.updates, "check_for_update", return_value=None)
284
+
285
+ rc = cli._cmd_status(_ns())
286
+
287
+ assert rc == 0
288
+ out = capsys.readouterr().out
289
+ assert "0.5.0" in out and "0.8.0" in out # the skew is surfaced
290
+ assert "restart" in out.lower()
291
+
271
292
  def test_stopped_returns_one(self, mocker, capsys):
272
293
  mocker.patch.object(cli.daemon, "is_running", return_value=False)
273
294
  rc = cli._cmd_status(_ns())
@@ -76,6 +76,22 @@ def test_ping_returns_ok(server: ServerHarness):
76
76
  assert _roundtrip(server.sock, json.dumps({"command": "ping"}) + "\n") == "ok"
77
77
 
78
78
 
79
+ def test_version_returns_installed_version(server: ServerHarness):
80
+ from stackvox import updates
81
+
82
+ reply = _roundtrip(server.sock, json.dumps({"command": "version"}) + "\n")
83
+ assert reply == updates._current_version()
84
+
85
+
86
+ def test_version_client_helper_reports_running_version(server: ServerHarness, monkeypatch):
87
+ from stackvox import daemon, updates
88
+
89
+ monkeypatch.setattr(daemon, "SOCKET_PATH", server.sock)
90
+ got, resp = daemon.version()
91
+ assert got is True
92
+ assert resp == updates._current_version()
93
+
94
+
79
95
  def test_plain_text_is_treated_as_text_field(server: ServerHarness):
80
96
  """Non-JSON payloads are accepted and wrapped as `{"text": line}`."""
81
97
  import time
@@ -35,7 +35,8 @@ def fake_kokoro(mocker, fake_ensure_models):
35
35
 
36
36
  @pytest.fixture
37
37
  def fake_audio(mocker):
38
- """Mock the sounddevice surface used by Stackvox."""
38
+ """Mock the sounddevice surface used by Stackvox (streaming + speak_sequence)."""
39
+ mocker.patch.object(engine.sd, "OutputStream")
39
40
  mocker.patch.object(engine.sd, "play")
40
41
  mocker.patch.object(engine.sd, "wait")
41
42
  mocker.patch.object(engine.sd, "stop")
@@ -105,15 +106,35 @@ class TestSpeak:
105
106
 
106
107
  engine.Stackvox().speak("One sentence. Two sentence. Three here.")
107
108
 
108
- # One synth + play cycle per sentence — that's what enables early audio.
109
+ # One synth per sentence (early audio), all streamed through a single
110
+ # output stream: a prime-silence write plus one write per sentence.
109
111
  assert fake_kokoro.return_value.create.call_count == 3
110
- assert engine.sd.play.call_count == 3
111
- assert engine.sd.wait.call_count == 3
112
+ engine.sd.OutputStream.assert_called_once()
113
+ stream = engine.sd.OutputStream.return_value
114
+ assert stream.write.call_count == 4 # 1 prime + 3 sentences
115
+ stream.close.assert_called_once()
112
116
 
113
117
  def test_single_sentence_plays_once(self, fake_kokoro, fake_audio):
114
118
  fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
115
119
  engine.Stackvox().speak("Just one sentence with no terminal punctuation")
116
- assert engine.sd.play.call_count == 1
120
+ engine.sd.OutputStream.assert_called_once()
121
+ assert engine.sd.OutputStream.return_value.write.call_count == 2 # prime + 1 sentence
122
+
123
+ def test_primes_with_silence_before_first_audio(self, fake_kokoro, fake_audio):
124
+ # The lead-in silence lets the output device (e.g. Bluetooth) warm up
125
+ # before the first word, rather than crackling into it.
126
+ fake_kokoro.return_value.create.return_value = (np.ones(10, dtype=np.float32), 24000)
127
+ engine.Stackvox().speak("hello")
128
+ prime = engine.sd.OutputStream.return_value.write.call_args_list[0].args[0]
129
+ assert not prime.any()
130
+ assert len(prime) == int(24000 * engine._PRIME_SILENCE_SECONDS)
131
+
132
+ def test_first_audio_fades_in_from_zero(self, fake_kokoro, fake_audio):
133
+ fake_kokoro.return_value.create.return_value = (np.ones(1000, dtype=np.float32), 24000)
134
+ engine.Stackvox().speak("hello")
135
+ first_audio = engine.sd.OutputStream.return_value.write.call_args_list[1].args[0]
136
+ assert first_audio[0] == 0.0 # ramp starts at silence
137
+ assert first_audio[-1] == 1.0 # ramp completes, body untouched
117
138
 
118
139
  def test_non_blocking_plays_in_background_thread(self, fake_kokoro, fake_audio):
119
140
  fake_kokoro.return_value.create.return_value = (np.zeros(10, dtype=np.float32), 24000)
@@ -123,7 +144,7 @@ class TestSpeak:
123
144
 
124
145
  assert tts._play_thread is not None
125
146
  tts._play_thread.join(timeout=5)
126
- engine.sd.play.assert_called()
147
+ engine.sd.OutputStream.assert_called()
127
148
 
128
149
  def test_passes_engine_defaults_to_synthesis(self, fake_kokoro, fake_audio):
129
150
  fake_kokoro.return_value.create.return_value = (np.zeros(4, dtype=np.float32), 24000)
@@ -177,7 +198,7 @@ class TestSpeak:
177
198
  tts = engine.Stackvox()
178
199
  tts.stop()
179
200
  tts.speak("hello world")
180
- engine.sd.play.assert_called()
201
+ engine.sd.OutputStream.assert_called()
181
202
 
182
203
 
183
204
  class TestSplitSentences:
@@ -10,6 +10,7 @@ from stackvox.text import (
10
10
  shape_pauses,
11
11
  strip_emoji,
12
12
  strip_thousands_separators,
13
+ versions_to_words,
13
14
  )
14
15
 
15
16
  # --- numbers ---------------------------------------------------------------
@@ -28,6 +29,31 @@ def test_year_is_not_a_decimal():
28
29
  assert decimals_to_words("built in 2023") == "built in 2023"
29
30
 
30
31
 
32
+ def test_semver_all_parts_spoken():
33
+ assert versions_to_words("0.7.0") == "0 point 7 point 0"
34
+ assert versions_to_words("1.2.3") == "1 point 2 point 3"
35
+
36
+
37
+ def test_semver_multi_digit_component():
38
+ assert versions_to_words("1.20.3") == "1 point 20 point 3"
39
+
40
+
41
+ def test_version_preserves_trailing_sentence_stop():
42
+ assert versions_to_words("upgrade to 1.2.3.") == "upgrade to 1 point 2 point 3."
43
+
44
+
45
+ def test_two_part_number_is_left_for_the_decimal_pass():
46
+ # Only two components — an ordinary decimal, not a version.
47
+ assert versions_to_words("3.14") == "3.14"
48
+ assert decimals_to_words(versions_to_words("3.14")) == "3 point 1 4"
49
+
50
+
51
+ def test_semver_normalizes_end_to_end():
52
+ out = normalize_for_speech("Upgraded to 0.7.0 today.", markdown=False)
53
+ assert "0 point 7 point 0" in out
54
+ assert "0 point 7.0" not in out
55
+
56
+
31
57
  # --- units -----------------------------------------------------------------
32
58
 
33
59
 
@@ -195,3 +221,28 @@ def test_speaklast_style_normalization():
195
221
  def test_plain_text_mode_splits_paragraphs():
196
222
  out = normalize_for_speech("First para.\n\nSecond para.", markdown=False)
197
223
  assert out == "First para.\nSecond para."
224
+
225
+
226
+ def test_dev_acronyms_are_spelled_out():
227
+ assert normalize_for_speech("Use the CLI.", markdown=False) == "Use the C L I."
228
+ assert "C L I" in normalize_for_speech("the cli tool", markdown=False) # lowercase too
229
+ assert "A W S" in normalize_for_speech("deploy to AWS", markdown=False)
230
+ assert "U R I" in normalize_for_speech("parse the URI", markdown=False)
231
+ assert "C I C D" in normalize_for_speech("the CI/CD pipeline", markdown=False)
232
+
233
+
234
+ def test_dev_terms_leave_correctly_voiced_acronyms_alone():
235
+ out = normalize_for_speech("Send JSON over HTTP to the API.", markdown=False)
236
+ assert "JSON" in out
237
+ assert "HTTP" in out
238
+ assert "API" in out
239
+
240
+
241
+ def test_dev_terms_can_be_disabled():
242
+ assert normalize_for_speech("Use the CLI.", markdown=False, dev_terms=False) == "Use the CLI."
243
+
244
+
245
+ def test_caller_pronunciations_override_dev_terms():
246
+ out = normalize_for_speech("Use the CLI.", markdown=False, pronunciations={"CLI": "command line"})
247
+ assert "command line" in out
248
+ assert "C L I" not in out
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes