python-voiceio 0.9.5__tar.gz → 0.9.7__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 (131) hide show
  1. {python_voiceio-0.9.5/python_voiceio.egg-info → python_voiceio-0.9.7}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.5 → python_voiceio-0.9.7/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_postcorrect.py +72 -1
  5. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_postprocess.py +80 -1
  6. python_voiceio-0.9.7/voiceio/__init__.py +1 -0
  7. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/app.py +3 -0
  8. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/config.py +7 -0
  9. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/postcorrect.py +87 -19
  10. python_voiceio-0.9.7/voiceio/postprocess.py +164 -0
  11. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/streaming.py +3 -0
  12. python_voiceio-0.9.5/voiceio/__init__.py +0 -1
  13. python_voiceio-0.9.5/voiceio/postprocess.py +0 -95
  14. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/LICENSE +0 -0
  15. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/README.md +0 -0
  16. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/SOURCES.txt +0 -0
  17. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/dependency_links.txt +0 -0
  18. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/entry_points.txt +0 -0
  19. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/requires.txt +0 -0
  20. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/top_level.txt +0 -0
  21. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/setup.cfg +0 -0
  22. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_adjudicate.py +0 -0
  23. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_app_wiring.py +0 -0
  24. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_audio_quality.py +0 -0
  25. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_audit.py +0 -0
  26. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_autocorrect.py +0 -0
  27. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_autocorrect_state.py +0 -0
  28. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_backend_probes.py +0 -0
  29. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_cli.py +0 -0
  30. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_clipboard_read.py +0 -0
  31. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_commands.py +0 -0
  32. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_concurrency_lockdown.py +0 -0
  33. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_config.py +0 -0
  34. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_correct_batch.py +0 -0
  35. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_corrections.py +0 -0
  36. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_evaluate.py +0 -0
  37. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_fallback.py +0 -0
  38. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_health.py +0 -0
  39. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_hints.py +0 -0
  40. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_history.py +0 -0
  41. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_ibus_pending.py +0 -0
  42. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_ibus_ping.py +0 -0
  43. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_ibus_typer.py +0 -0
  44. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_llm.py +0 -0
  45. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_llm_api.py +0 -0
  46. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_numbers.py +0 -0
  47. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_platform.py +0 -0
  48. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_prebuffer.py +0 -0
  49. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_prompt.py +0 -0
  50. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_recorder_integration.py +0 -0
  51. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_retention.py +0 -0
  52. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_robustness.py +0 -0
  53. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_security_hardening.py +0 -0
  54. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_snapshots.py +0 -0
  55. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_streaming.py +0 -0
  56. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_tokens.py +0 -0
  57. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_transcriber.py +0 -0
  58. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_tts.py +0 -0
  59. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_vad.py +0 -0
  60. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_vocabulary.py +0 -0
  61. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_wizard.py +0 -0
  62. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_wordfreq.py +0 -0
  63. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/__main__.py +0 -0
  64. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/audit.py +0 -0
  65. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/autocorrect.py +0 -0
  66. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/autocorrect_state.py +0 -0
  67. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/backends.py +0 -0
  68. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/cli.py +0 -0
  69. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/clipboard_read.py +0 -0
  70. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/commands.py +0 -0
  71. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/consent.py +0 -0
  72. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/corrections.py +0 -0
  73. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/demo.py +0 -0
  74. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/evaluate.py +0 -0
  75. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/feedback.py +0 -0
  76. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/health.py +0 -0
  77. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hints.py +0 -0
  78. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/history.py +0 -0
  79. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/__init__.py +0 -0
  80. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/base.py +0 -0
  81. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/chain.py +0 -0
  82. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/evdev.py +0 -0
  83. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/pynput_backend.py +0 -0
  84. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/socket_backend.py +0 -0
  85. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/ibus/__init__.py +0 -0
  86. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/ibus/engine.py +0 -0
  87. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/ibus/pending.py +0 -0
  88. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/llm.py +0 -0
  89. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/llm_api.py +0 -0
  90. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/models/__init__.py +0 -0
  91. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/models/silero_vad.onnx +0 -0
  92. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/numbers.py +0 -0
  93. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/pidlock.py +0 -0
  94. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/platform.py +0 -0
  95. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/prompt.py +0 -0
  96. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/recorder.py +0 -0
  97. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/retention.py +0 -0
  98. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/service.py +0 -0
  99. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/snapshots.py +0 -0
  100. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/__init__.py +0 -0
  101. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/commit.wav +0 -0
  102. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/start.wav +0 -0
  103. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/stop.wav +0 -0
  104. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tokens.py +0 -0
  105. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/transcriber.py +0 -0
  106. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/__init__.py +0 -0
  107. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/_icons.py +0 -0
  108. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/_indicator.py +0 -0
  109. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/_pystray.py +0 -0
  110. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/__init__.py +0 -0
  111. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/base.py +0 -0
  112. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/chain.py +0 -0
  113. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/edge_engine.py +0 -0
  114. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/espeak.py +0 -0
  115. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/piper_engine.py +0 -0
  116. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/player.py +0 -0
  117. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/__init__.py +0 -0
  118. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/base.py +0 -0
  119. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/chain.py +0 -0
  120. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/clipboard.py +0 -0
  121. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/ibus.py +0 -0
  122. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/pynput_type.py +0 -0
  123. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/wtype.py +0 -0
  124. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/xdotool.py +0 -0
  125. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/ydotool.py +0 -0
  126. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/vad.py +0 -0
  127. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/vocab_stats.py +0 -0
  128. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/vocabulary.py +0 -0
  129. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/wizard.py +0 -0
  130. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/wordfreq.py +0 -0
  131. {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.5
3
+ Version: 0.9.7
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "python-voiceio"
7
- version = "0.9.5"
7
+ version = "0.9.7"
8
8
  description = "Voice dictation for Linux. Speak → text, locally, instantly."
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.5
3
+ Version: 0.9.7
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -7,11 +7,13 @@ from voiceio.config import Config, PostCorrectConfig
7
7
  from voiceio.postcorrect import PostCorrector
8
8
 
9
9
 
10
- def _cfg(*, enabled=True, api_key="test-key", min_words=4, model="") -> Config:
10
+ def _cfg(*, enabled=True, api_key="test-key", min_words=4, model="",
11
+ remove_disfluencies=False) -> Config:
11
12
  cfg = Config()
12
13
  cfg.postcorrect = PostCorrectConfig(
13
14
  enabled=enabled, min_words=min_words, model=model,
14
15
  )
16
+ cfg.output.remove_disfluencies = remove_disfluencies
15
17
  cfg.autocorrect.api_key = api_key
16
18
  return cfg
17
19
 
@@ -79,6 +81,75 @@ def test_word_count_guard_rejects_length_change():
79
81
  assert pc.correct(original) == original
80
82
 
81
83
 
84
+ # ── disfluency mode: delete-only, never change meaning ───────────────────
85
+
86
+
87
+ def test_disfluency_mode_allows_deletion():
88
+ """Removing filler shrinks the text well past the 20% fix-mode ceiling —
89
+ the disfluency guard must allow it (deletions only)."""
90
+ pc = PostCorrector(_cfg(remove_disfluencies=True))
91
+ original = "so um we should like ship the thing today"
92
+ cleaned = "so we should ship the thing today" # dropped 'um' and 'like'
93
+ with patch("voiceio.llm_api.chat", return_value=cleaned):
94
+ assert pc.correct(original) == cleaned
95
+
96
+
97
+ def test_disfluency_mode_rejects_insertion():
98
+ """Adding any word could alter meaning — reject, keep the original."""
99
+ pc = PostCorrector(_cfg(remove_disfluencies=True))
100
+ original = "we should ship the thing today"
101
+ added = "we should ship the thing today and also test everything first"
102
+ with patch("voiceio.llm_api.chat", return_value=added):
103
+ assert pc.correct(original) == original
104
+
105
+
106
+ def test_disfluency_mode_rejects_overdeletion():
107
+ """Deleting more than the cap means real content was stripped — reject."""
108
+ pc = PostCorrector(_cfg(remove_disfluencies=True))
109
+ original = "please run the full test suite before you deploy tonight okay"
110
+ gutted = "please run test deploy" # >40% of words gone
111
+ with patch("voiceio.llm_api.chat", return_value=gutted):
112
+ assert pc.correct(original) == original
113
+
114
+
115
+ def test_disfluency_mode_rejects_rewording():
116
+ """Many substitutions = a rewrite, not filler removal — reject."""
117
+ pc = PostCorrector(_cfg(remove_disfluencies=True))
118
+ original = "the server crashed at midnight during the backup"
119
+ reworded = "the machine went down at noon while copying files"
120
+ with patch("voiceio.llm_api.chat", return_value=reworded):
121
+ assert pc.correct(original) == original
122
+
123
+
124
+ def test_disfluency_mode_rejects_dropped_negation():
125
+ """Dropping 'not' is one deletion under every fraction cap but inverts
126
+ meaning — the negation guard must reject it."""
127
+ pc = PostCorrector(_cfg(remove_disfluencies=True))
128
+ original = "I do not want the feature to ship today at all"
129
+ inverted = "I do want the feature to ship today at all"
130
+ with patch("voiceio.llm_api.chat", return_value=inverted):
131
+ assert pc.correct(original) == original
132
+
133
+
134
+ def test_disfluency_mode_rejects_replaced_negation():
135
+ """Replacing a negation with something else also inverts meaning."""
136
+ pc = PostCorrector(_cfg(remove_disfluencies=True))
137
+ original = "we should never deploy on a friday afternoon"
138
+ changed = "we should always deploy on a friday afternoon"
139
+ with patch("voiceio.llm_api.chat", return_value=changed):
140
+ assert pc.correct(original) == original
141
+
142
+
143
+ def test_fix_mode_still_rejects_big_deletion():
144
+ """With disfluency mode OFF, the original conservative guards stand: a big
145
+ deletion is a length change and must be rejected."""
146
+ pc = PostCorrector(_cfg(remove_disfluencies=False))
147
+ original = "so um we should like ship the thing today"
148
+ cleaned = "so we should ship the thing today"
149
+ with patch("voiceio.llm_api.chat", return_value=cleaned):
150
+ assert pc.correct(original) == original
151
+
152
+
82
153
  def test_llm_error_returns_original():
83
154
  pc = PostCorrector(_cfg())
84
155
  with patch("voiceio.llm_api.chat", side_effect=RuntimeError("boom")):
@@ -1,10 +1,89 @@
1
1
  """Tests for text post-processing (punctuation, capitalization)."""
2
2
  from __future__ import annotations
3
3
 
4
- from voiceio.postprocess import apply_pipeline, cleanup
4
+ from voiceio.postprocess import apply_pipeline, cleanup, strip_disfluencies
5
5
  from voiceio.streaming import _word_match_len
6
6
 
7
7
 
8
+ class TestStripDisfluencies:
9
+ """Delete-only regex layer: filler sounds + duplicate re-decode sentences,
10
+ never anything that could change meaning."""
11
+
12
+ def test_removes_filler_sounds(self):
13
+ out = strip_disfluencies("so um we should uh ship it")
14
+ assert "um" not in out.split()
15
+ assert "uh" not in out.split()
16
+ assert "we should" in out and "ship it" in out
17
+
18
+ def test_removes_comma_wrapped_filler(self):
19
+ assert "uh" not in strip_disfluencies("we tested, uh, everything").split()
20
+
21
+ def test_preserves_valid_word_repetition(self):
22
+ # "had had" is valid English; regex must NOT touch word repeats — that
23
+ # judgment belongs to the LLM layer.
24
+ assert strip_disfluencies("I had had enough") == "I had had enough"
25
+
26
+ def test_dedups_duplicate_sentence(self):
27
+ # The Whisper re-decode artifact: a whole sentence repeated verbatim.
28
+ out = strip_disfluencies("Do the deep research now. Do the deep research now.")
29
+ assert out == "Do the deep research now."
30
+
31
+ def test_keeps_meaningful_words(self):
32
+ # 'like' as a real verb/preposition and content must survive.
33
+ text = "I like the design and it works like a charm"
34
+ assert strip_disfluencies(text) == text
35
+
36
+ def test_never_eats_real_words_or_units(self):
37
+ # Default-on runs on everyone's speech: filler patterns must not collide
38
+ # with real words, units, or abbreviations. Case-sensitive matching is
39
+ # what protects the all-caps abbreviations (ER, UM, HM).
40
+ for text in [
41
+ "to err is human",
42
+ "we should err on caution",
43
+ "the bolt is 5 mm wide",
44
+ "set it to 10 mm please",
45
+ "ah yes I remember now",
46
+ "I like the ohm rating",
47
+ "Take him to the ER right now", # ER = emergency room
48
+ "The UM campus in Michigan", # UM = University of Michigan
49
+ "The Er atom is a lanthanide", # Er = erbium
50
+ "we measured 3 hm across", # hm = hectometre (bare, 1 m)
51
+ ]:
52
+ assert strip_disfluencies(text) == text, text
53
+
54
+ def test_preserves_newlines_and_structure(self):
55
+ # A filler on its own line must not swallow the paragraph break.
56
+ assert strip_disfluencies("First para.\n\nSecond para.") == \
57
+ "First para.\n\nSecond para."
58
+ assert "\n\n" in strip_disfluencies("First para.\n\num\n\nSecond para.")
59
+
60
+ def test_uh_huh_removed_whole(self):
61
+ # Regression: ordering bug once stranded "-huh".
62
+ assert strip_disfluencies("uh-huh right") == "right"
63
+ assert strip_disfluencies("uh huh yes") == "yes"
64
+
65
+ def test_preserves_emphatic_short_repeat(self):
66
+ # Short repeats are emphasis, not a re-decode artifact — keep them.
67
+ assert strip_disfluencies("No. No.") == "No. No."
68
+
69
+ def test_empty(self):
70
+ assert strip_disfluencies("") == ""
71
+
72
+
73
+ class TestPipelineDisfluencies:
74
+ def test_flag_off_keeps_fillers(self):
75
+ text, _ = apply_pipeline("um hello there friend", do_cleanup=True, final=True)
76
+ assert "um" in text.lower().split()
77
+
78
+ def test_flag_on_strips_fillers(self):
79
+ text, _ = apply_pipeline(
80
+ "um hello there friend",
81
+ do_cleanup=True, remove_disfluencies=True, final=True,
82
+ )
83
+ assert "um" not in text.lower().split()
84
+ assert "hello there friend" in text.lower()
85
+
86
+
8
87
  class TestCapitalization:
9
88
  def test_first_char(self):
10
89
  assert cleanup("hello world") == "Hello world"
@@ -0,0 +1 @@
1
+ __version__ = "0.9.7"
@@ -188,6 +188,7 @@ class VoiceIO:
188
188
  self._command_processor = CommandProcessor(enabled=cfg.commands.enabled, editing=cfg.commands.editing)
189
189
  self._cleanup = cfg.output.punctuation_cleanup
190
190
  self._number_conversion = cfg.output.number_conversion
191
+ self._remove_disfluencies = cfg.output.remove_disfluencies
191
192
  self._voice_input_prefix = cfg.output.voice_input_prefix
192
193
  self._streaming = cfg.output.streaming
193
194
 
@@ -458,6 +459,7 @@ class VoiceIO:
458
459
  self.transcriber, self._typer, self.recorder,
459
460
  generation=self._generation,
460
461
  cleanup=self._cleanup,
462
+ remove_disfluencies=self._remove_disfluencies,
461
463
  number_conversion=self._number_conversion,
462
464
  language=self.cfg.model.language,
463
465
  commands=self._command_processor,
@@ -669,6 +671,7 @@ class VoiceIO:
669
671
  text, abort = apply_pipeline(
670
672
  text,
671
673
  do_cleanup=self._cleanup,
674
+ remove_disfluencies=self._remove_disfluencies,
672
675
  number_conversion=self._number_conversion,
673
676
  language=self.cfg.model.language,
674
677
  commands=self._command_processor,
@@ -88,6 +88,13 @@ class OutputConfig:
88
88
  cancel_window_secs: float = 0.5
89
89
  punctuation_cleanup: bool = True
90
90
  number_conversion: bool = True
91
+ # Strip spoken disfluencies so dictation reads as writing. Delete-only and
92
+ # meaning-preserving: a free regex layer removes filler sounds (um/uh/er)
93
+ # and duplicate re-decode sentences on every pass; when [postcorrect] is
94
+ # also enabled, its LLM pass additionally removes false starts and filler
95
+ # "like", guarded so it can only delete — never add, rephrase, or reorder.
96
+ # On by default: nobody wants "um" in their notes. Set false for verbatim.
97
+ remove_disfluencies: bool = True
91
98
  voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
92
99
  # Incremental finalization: once the un-finalized audio tail grows past
93
100
  # this many seconds, it is cut at the nearest interior speech pause,
@@ -26,6 +26,27 @@ log = logging.getLogger(__name__)
26
26
  _MAX_EDIT_RATIO = 0.3 # word-level SequenceMatcher edit ratio ceiling
27
27
  _MAX_WORDCOUNT_DELTA = 0.2 # allowed relative change in word count
28
28
 
29
+ # Disfluency-mode guards. The whole promise is "never change meaning", enforced
30
+ # structurally on the word-level diff: no INSERTIONS (can't add or rephrase),
31
+ # few REPLACEMENTS (ASR fixes only, not reword), bounded DELETIONS (can't nuke
32
+ # real content). Deletions are what disfluency removal legitimately does.
33
+ _MAX_INSERTED_WORDS = 0 # adding any word = altering meaning → reject
34
+ _MAX_REPLACE_FRAC = 0.15 # ASR word-fixes only, never wholesale rewording
35
+ _MAX_DELETE_FRAC = 0.4 # backstop against deleting real content
36
+
37
+ # Words whose deletion/replacement flips meaning — the fraction caps can't catch
38
+ # a single dropped "not". If the edit touches any of these on the original side,
39
+ # reject outright. (Contractions ending in "n't" are handled separately.)
40
+ _MEANING_CRITICAL = frozenset({
41
+ "not", "no", "never", "none", "nor", "neither", "without", "cannot",
42
+ "nothing", "nobody", "nowhere", "n't",
43
+ })
44
+
45
+
46
+ def _is_meaning_critical(word: str) -> bool:
47
+ w = word.lower().strip(".,;:!?\"'()")
48
+ return w in _MEANING_CRITICAL or w.endswith("n't")
49
+
29
50
  _SYSTEM_PROMPT = (
30
51
  "You fix automatic speech recognition errors in dictated text. "
31
52
  "The user dictates about software engineering and their projects. "
@@ -35,6 +56,23 @@ _SYSTEM_PROMPT = (
35
56
  "Return only the corrected text with no commentary."
36
57
  )
37
58
 
59
+ # Disfluency mode: also strip spoken filler, delete-only. The strict rules
60
+ # mirror the guards — the model is told exactly what the diff check enforces.
61
+ _SYSTEM_PROMPT_CLEAN = (
62
+ "You convert dictated speech into clean written text. The user dictates "
63
+ "about software engineering and their projects. Do exactly two things:\n"
64
+ "1. Fix words the recognizer misheard (wrong proper nouns, homophones, "
65
+ "garbled technical terms).\n"
66
+ "2. Remove speech disfluencies: filler sounds (um, uh, er); filler uses of "
67
+ "'like', 'you know', 'I mean'; false starts and self-corrections (keep the "
68
+ "corrected version); and stray word repetitions.\n"
69
+ "STRICT RULES: Only DELETE disfluencies and FIX misheard words. NEVER add "
70
+ "words. NEVER rephrase, reword, reorder, or summarize. NEVER drop real "
71
+ "content, meaningful hedges, or negations. If unsure whether something is a "
72
+ "disfluency, KEEP it. Preserve the speaker's own wording and punctuation. "
73
+ "Return only the cleaned text, nothing else."
74
+ )
75
+
38
76
  _MAX_RECENT = 3
39
77
 
40
78
 
@@ -82,6 +120,7 @@ class PostCorrector:
82
120
  def __init__(self, cfg: Config):
83
121
  self._cfg = cfg
84
122
  self._pc = cfg.postcorrect
123
+ self._remove_disfluencies = cfg.output.remove_disfluencies
85
124
  # API key / base_url resolution is shared with [autocorrect].
86
125
  self._ac = cfg.autocorrect
87
126
  self._available: bool | None = None
@@ -210,10 +249,14 @@ class PostCorrector:
210
249
  t0 = time.monotonic()
211
250
  outcome: dict = {}
212
251
 
252
+ system_prompt = (
253
+ _SYSTEM_PROMPT_CLEAN if self._remove_disfluencies else _SYSTEM_PROMPT
254
+ )
255
+
213
256
  def _call() -> None:
214
257
  try:
215
258
  outcome["response"] = chat(
216
- self._client_cfg(), _SYSTEM_PROMPT, user_msg, max_tokens=1024,
259
+ self._client_cfg(), system_prompt, user_msg, max_tokens=1024,
217
260
  )
218
261
  except Exception as e:
219
262
  outcome["error"] = e
@@ -251,26 +294,51 @@ class PostCorrector:
251
294
  self._record(text, corrected, "unchanged")
252
295
  return text
253
296
 
254
- # Guard: word-count must not change materially.
255
- orig_wc, new_wc = len(text.split()), len(corrected.split())
256
- if orig_wc and abs(new_wc - orig_wc) / orig_wc > _MAX_WORDCOUNT_DELTA:
257
- log.debug(
258
- "PostCorrector reject: word count %d→%d (>%.0f%%) — keeping original",
259
- orig_wc, new_wc, _MAX_WORDCOUNT_DELTA * 100,
260
- )
261
- self._record(text, corrected, "rejected_wordcount")
262
- return text
263
-
264
- # Guard: only a small fraction of words may change.
265
- ratio = _word_edit_ratio(text, corrected)
266
- if ratio > _MAX_EDIT_RATIO:
267
- log.debug(
268
- "PostCorrector reject: edit ratio %.2f > %.2f — keeping original",
269
- ratio, _MAX_EDIT_RATIO,
270
- )
271
- self._record(text, corrected, "rejected_editratio")
297
+ accept, reject_reason = self._guard(text, corrected)
298
+ if not accept:
299
+ log.debug("PostCorrector reject (%s) keeping original", reject_reason)
300
+ self._record(text, corrected, f"rejected_{reject_reason}")
272
301
  return text
273
302
 
274
303
  log.info("PostCorrector fixed: %s", ", ".join(_changed_words(text, corrected)))
275
304
  self._record(text, corrected, "applied")
276
305
  return corrected
306
+
307
+ def _guard(self, text: str, corrected: str) -> tuple[bool, str]:
308
+ """Decide whether the LLM's edit is within bounds.
309
+
310
+ Returns (accept, reject_reason). In disfluency mode the promise is
311
+ "never change meaning", enforced on the word-level diff: zero
312
+ insertions (nothing added/rephrased), few replacements (ASR fixes, not
313
+ rewording), bounded deletions (can't strip real content). Otherwise the
314
+ original conservative fix-only guards apply.
315
+ """
316
+ aw, bw = text.split(), corrected.split()
317
+ if not self._remove_disfluencies:
318
+ n = len(aw)
319
+ if n and abs(len(bw) - n) / n > _MAX_WORDCOUNT_DELTA:
320
+ return False, "wordcount"
321
+ if _word_edit_ratio(text, corrected) > _MAX_EDIT_RATIO:
322
+ return False, "editratio"
323
+ return True, ""
324
+
325
+ inserted = replaced = deleted = 0
326
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=aw, b=bw).get_opcodes():
327
+ if tag == "insert":
328
+ inserted += j2 - j1
329
+ elif tag == "replace":
330
+ replaced += max(i2 - i1, j2 - j1)
331
+ if any(_is_meaning_critical(w) for w in aw[i1:i2]):
332
+ return False, "negation" # e.g. "not" → something else
333
+ elif tag == "delete":
334
+ deleted += i2 - i1
335
+ if any(_is_meaning_critical(w) for w in aw[i1:i2]):
336
+ return False, "negation" # dropping "not" inverts meaning
337
+ n = len(aw)
338
+ if inserted > _MAX_INSERTED_WORDS:
339
+ return False, "inserted" # added content — meaning changed
340
+ if replaced > max(3, int(_MAX_REPLACE_FRAC * n)):
341
+ return False, "reworded" # too many substitutions = rewrite
342
+ if n and deleted > _MAX_DELETE_FRAC * n:
343
+ return False, "overdeleted" # nuked real content
344
+ return True, ""
@@ -0,0 +1,164 @@
1
+ """Rule-based text cleanup for Whisper output. Near-zero latency."""
2
+ from __future__ import annotations
3
+
4
+ import re
5
+ from typing import TYPE_CHECKING
6
+
7
+ if TYPE_CHECKING:
8
+ from voiceio.commands import CommandProcessor
9
+ from voiceio.corrections import CorrectionDict
10
+ from voiceio.llm import LLMProcessor
11
+ from voiceio.postcorrect import PostCorrector
12
+
13
+ # Languages that don't use letter casing
14
+ _NO_CASE_LANGUAGES = frozenset({"zh", "ja", "ko", "ar", "he", "th", "hi", "bn", "ka", "my"})
15
+
16
+ # Filler SOUNDS only — tokens with no lexical meaning, so deleting them can
17
+ # never change meaning. Deliberately conservative because this runs on every
18
+ # user's speech:
19
+ # * CASE-SENSITIVE (no IGNORECASE): all-caps abbreviations that look like
20
+ # fillers are real words and must survive — "ER" (emergency room), "UM"
21
+ # (University of Michigan), "HM". We match lowercase and Title-case forms
22
+ # ("um", "Um") — Whisper's filler spellings — but never all-caps.
23
+ # * Excluded entirely: "er"/"erm" ("ER"/"Er"=erbium), "mm" (millimetres),
24
+ # "ah" (interjection), bare "hm" (hectometre — require "hmm", 2+ m's).
25
+ # * Surrounding whitespace is horizontal-only ([^\S\n]) so a filler on its
26
+ # own line doesn't swallow the paragraph/list break around it.
27
+ # Word repetitions ("had had" is valid English) and filler "like"/"you know"
28
+ # need judgment and are left to the LLM layer. Order matters: multi-token
29
+ # "uh-huh" before "[Uu]h+" so it isn't clipped to a stray "-huh".
30
+ _FILLER_RE = re.compile(
31
+ r"[^\S\n]*,?[^\S\n]*\b(?:[Uu]h[-\s]?huh|[Mm]hm|[Uu]h+m*|[Uu]m+|[Hh]m{2,})\b[^\S\n]*,?[^\S\n]*",
32
+ )
33
+ # A re-decode artifact is a whole duplicated sentence; require this many words
34
+ # so emphatic short repeats ("No. No.", "Stop. Stop.") are preserved.
35
+ _MIN_DEDUP_WORDS = 4
36
+
37
+
38
+ def strip_disfluencies(text: str) -> str:
39
+ """Delete-only, meaning-safe disfluency cleanup (regex layer).
40
+
41
+ Removes filler sounds (um, uh, er, …) and exact duplicate adjacent
42
+ sentences (a Whisper re-decode artifact). Only ever deletes — never
43
+ rephrases, reorders, or touches lexical words — so meaning is preserved by
44
+ construction. The judgment cases (false starts, filler "like", word
45
+ repetitions) are handled by the guarded LLM layer in postcorrect.
46
+ """
47
+ if not text:
48
+ return text
49
+ text = _FILLER_RE.sub(" ", text)
50
+ text = _dedup_adjacent_sentences(text)
51
+ # Repair the debris the deletions leave, without crossing newlines (so
52
+ # paragraph/list structure survives even when punctuation_cleanup is off).
53
+ text = re.sub(r"[^\S\n]+([,.;:?!])", r"\1", text) # space before punctuation
54
+ text = re.sub(r"[^\S\n]{2,}", " ", text) # collapse runs of spaces
55
+ return text.strip()
56
+
57
+
58
+ def _dedup_adjacent_sentences(text: str) -> str:
59
+ """Drop a sentence identical to the one immediately before it.
60
+
61
+ Separators are captured and preserved on rejoin so paragraph/list breaks
62
+ survive; only a full (>= _MIN_DEDUP_WORDS) verbatim repeat is removed.
63
+ """
64
+ tokens = re.split(r"((?<=[.?!])\s+)", text) # [sent, sep, sent, sep, …]
65
+ out: list[str] = []
66
+ last_kept: str | None = None
67
+ for i in range(0, len(tokens), 2):
68
+ sentence = tokens[i]
69
+ sep = tokens[i + 1] if i + 1 < len(tokens) else ""
70
+ norm = sentence.strip().lower()
71
+ if (last_kept is not None and norm == last_kept
72
+ and len(sentence.split()) >= _MIN_DEDUP_WORDS):
73
+ continue # drop the duplicate sentence and its separator
74
+ out.append(sentence)
75
+ out.append(sep)
76
+ last_kept = norm
77
+ return "".join(out)
78
+
79
+
80
+ def cleanup(text: str, language: str = "en") -> str:
81
+ """Clean up Whisper output. Idempotent — safe to run multiple times."""
82
+ if not text:
83
+ return text
84
+
85
+ text = text.strip()
86
+ if not text:
87
+ return text
88
+
89
+ # Normalize multiple spaces to single
90
+ text = re.sub(r" {2,}", " ", text)
91
+
92
+ # Ensure space after sentence-ending punctuation before a letter
93
+ text = re.sub(r"([.?!])([A-Za-z\u00C0-\u024F])", r"\1 \2", text)
94
+
95
+ # Remove space before punctuation marks
96
+ text = re.sub(r"\s+([.,;:?!])", r"\1", text)
97
+
98
+ if language not in _NO_CASE_LANGUAGES:
99
+ # Capitalize first character
100
+ text = text[0].upper() + text[1:]
101
+
102
+ # Capitalize after sentence-ending punctuation
103
+ text = re.sub(
104
+ r"([.?!]\s+)([a-z\u00E0-\u00FF])",
105
+ lambda m: m.group(1) + m.group(2).upper(),
106
+ text,
107
+ )
108
+
109
+ return text
110
+
111
+
112
+ def apply_pipeline(
113
+ text: str,
114
+ *,
115
+ do_cleanup: bool = False,
116
+ remove_disfluencies: bool = False,
117
+ number_conversion: bool = False,
118
+ language: str = "en",
119
+ commands: CommandProcessor | None = None,
120
+ corrections: CorrectionDict | None = None,
121
+ postcorrect: PostCorrector | None = None,
122
+ llm: LLMProcessor | None = None,
123
+ voice_input_prefix: str = "",
124
+ final: bool = False,
125
+ ) -> tuple[str, bool]:
126
+ """Shared post-processing pipeline used by both streaming and batch modes.
127
+
128
+ Returns (processed_text, abort). If abort is True, the caller should
129
+ discard the result (e.g. undo/flag command was triggered).
130
+ """
131
+ # Disfluency removal runs BEFORE cleanup so cleanup re-fixes the spacing,
132
+ # commas, and capitalization the deletions leave behind. Delete-only.
133
+ if remove_disfluencies:
134
+ text = strip_disfluencies(text)
135
+
136
+ if do_cleanup:
137
+ text = cleanup(text, language)
138
+
139
+ if number_conversion:
140
+ from voiceio.numbers import convert_numbers
141
+ text = convert_numbers(text, language)
142
+
143
+ if commands:
144
+ text = commands.process(text, final=final)
145
+ if commands.undo_requested or commands.flag_requested:
146
+ if commands.flag_requested and corrections and commands.flagged_word:
147
+ corrections.flag_word(commands.flagged_word)
148
+ return "", True
149
+
150
+ if corrections and text:
151
+ text = corrections.apply(text)
152
+
153
+ # Constrained LLM post-correction — final pass only, before generic LLM.
154
+ if final and postcorrect and text:
155
+ text = postcorrect.correct(text)
156
+
157
+ if final and llm and text:
158
+ text = llm.process(text)
159
+
160
+ # Applied on every pass so the marker appears from the first streaming chunk.
161
+ if voice_input_prefix and text:
162
+ text = f"{voice_input_prefix} {text}"
163
+
164
+ return text, False
@@ -135,6 +135,7 @@ class StreamingSession:
135
135
  recorder: AudioRecorder,
136
136
  generation: int = 0,
137
137
  cleanup: bool = False,
138
+ remove_disfluencies: bool = False,
138
139
  number_conversion: bool = False,
139
140
  language: str = "en",
140
141
  commands: CommandProcessor | None = None,
@@ -158,6 +159,7 @@ class StreamingSession:
158
159
  self._sample_rate = recorder.sample_rate
159
160
  self._generation = generation
160
161
  self._cleanup = cleanup
162
+ self._remove_disfluencies = remove_disfluencies
161
163
  self._number_conversion = number_conversion
162
164
  self._language = language
163
165
  self._commands = commands
@@ -450,6 +452,7 @@ class StreamingSession:
450
452
  text, abort = apply_pipeline(
451
453
  raw,
452
454
  do_cleanup=self._cleanup,
455
+ remove_disfluencies=self._remove_disfluencies,
453
456
  number_conversion=self._number_conversion,
454
457
  language=self._language,
455
458
  commands=self._commands,
@@ -1 +0,0 @@
1
- __version__ = "0.9.5"
@@ -1,95 +0,0 @@
1
- """Rule-based text cleanup for Whisper output. Near-zero latency."""
2
- from __future__ import annotations
3
-
4
- import re
5
- from typing import TYPE_CHECKING
6
-
7
- if TYPE_CHECKING:
8
- from voiceio.commands import CommandProcessor
9
- from voiceio.corrections import CorrectionDict
10
- from voiceio.llm import LLMProcessor
11
- from voiceio.postcorrect import PostCorrector
12
-
13
- # Languages that don't use letter casing
14
- _NO_CASE_LANGUAGES = frozenset({"zh", "ja", "ko", "ar", "he", "th", "hi", "bn", "ka", "my"})
15
-
16
-
17
- def cleanup(text: str, language: str = "en") -> str:
18
- """Clean up Whisper output. Idempotent — safe to run multiple times."""
19
- if not text:
20
- return text
21
-
22
- text = text.strip()
23
- if not text:
24
- return text
25
-
26
- # Normalize multiple spaces to single
27
- text = re.sub(r" {2,}", " ", text)
28
-
29
- # Ensure space after sentence-ending punctuation before a letter
30
- text = re.sub(r"([.?!])([A-Za-z\u00C0-\u024F])", r"\1 \2", text)
31
-
32
- # Remove space before punctuation marks
33
- text = re.sub(r"\s+([.,;:?!])", r"\1", text)
34
-
35
- if language not in _NO_CASE_LANGUAGES:
36
- # Capitalize first character
37
- text = text[0].upper() + text[1:]
38
-
39
- # Capitalize after sentence-ending punctuation
40
- text = re.sub(
41
- r"([.?!]\s+)([a-z\u00E0-\u00FF])",
42
- lambda m: m.group(1) + m.group(2).upper(),
43
- text,
44
- )
45
-
46
- return text
47
-
48
-
49
- def apply_pipeline(
50
- text: str,
51
- *,
52
- do_cleanup: bool = False,
53
- number_conversion: bool = False,
54
- language: str = "en",
55
- commands: CommandProcessor | None = None,
56
- corrections: CorrectionDict | None = None,
57
- postcorrect: PostCorrector | None = None,
58
- llm: LLMProcessor | None = None,
59
- voice_input_prefix: str = "",
60
- final: bool = False,
61
- ) -> tuple[str, bool]:
62
- """Shared post-processing pipeline used by both streaming and batch modes.
63
-
64
- Returns (processed_text, abort). If abort is True, the caller should
65
- discard the result (e.g. undo/flag command was triggered).
66
- """
67
- if do_cleanup:
68
- text = cleanup(text, language)
69
-
70
- if number_conversion:
71
- from voiceio.numbers import convert_numbers
72
- text = convert_numbers(text, language)
73
-
74
- if commands:
75
- text = commands.process(text, final=final)
76
- if commands.undo_requested or commands.flag_requested:
77
- if commands.flag_requested and corrections and commands.flagged_word:
78
- corrections.flag_word(commands.flagged_word)
79
- return "", True
80
-
81
- if corrections and text:
82
- text = corrections.apply(text)
83
-
84
- # Constrained LLM post-correction — final pass only, before generic LLM.
85
- if final and postcorrect and text:
86
- text = postcorrect.correct(text)
87
-
88
- if final and llm and text:
89
- text = llm.process(text)
90
-
91
- # Applied on every pass so the marker appears from the first streaming chunk.
92
- if voice_input_prefix and text:
93
- text = f"{voice_input_prefix} {text}"
94
-
95
- return text, False
File without changes
File without changes
File without changes