python-voiceio 0.9.4__tar.gz → 0.9.6__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 (130) hide show
  1. {python_voiceio-0.9.4/python_voiceio.egg-info → python_voiceio-0.9.6}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.4 → python_voiceio-0.9.6/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_postcorrect.py +53 -1
  5. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_postprocess.py +47 -1
  6. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_vad.py +33 -10
  7. python_voiceio-0.9.6/voiceio/__init__.py +1 -0
  8. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/app.py +3 -0
  9. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/config.py +6 -0
  10. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/postcorrect.py +70 -19
  11. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/postprocess.py +43 -0
  12. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/streaming.py +3 -0
  13. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/vad.py +13 -3
  14. python_voiceio-0.9.4/voiceio/__init__.py +0 -1
  15. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/LICENSE +0 -0
  16. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/README.md +0 -0
  17. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/python_voiceio.egg-info/SOURCES.txt +0 -0
  18. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/python_voiceio.egg-info/dependency_links.txt +0 -0
  19. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/python_voiceio.egg-info/entry_points.txt +0 -0
  20. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/python_voiceio.egg-info/requires.txt +0 -0
  21. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/python_voiceio.egg-info/top_level.txt +0 -0
  22. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/setup.cfg +0 -0
  23. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_adjudicate.py +0 -0
  24. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_app_wiring.py +0 -0
  25. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_audio_quality.py +0 -0
  26. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_audit.py +0 -0
  27. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_autocorrect.py +0 -0
  28. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_autocorrect_state.py +0 -0
  29. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_backend_probes.py +0 -0
  30. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_cli.py +0 -0
  31. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_clipboard_read.py +0 -0
  32. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_commands.py +0 -0
  33. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_concurrency_lockdown.py +0 -0
  34. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_config.py +0 -0
  35. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_correct_batch.py +0 -0
  36. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_corrections.py +0 -0
  37. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_evaluate.py +0 -0
  38. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_fallback.py +0 -0
  39. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_health.py +0 -0
  40. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_hints.py +0 -0
  41. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_history.py +0 -0
  42. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_ibus_pending.py +0 -0
  43. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_ibus_ping.py +0 -0
  44. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_ibus_typer.py +0 -0
  45. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_llm.py +0 -0
  46. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_llm_api.py +0 -0
  47. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_numbers.py +0 -0
  48. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_platform.py +0 -0
  49. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_prebuffer.py +0 -0
  50. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_prompt.py +0 -0
  51. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_recorder_integration.py +0 -0
  52. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_retention.py +0 -0
  53. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_robustness.py +0 -0
  54. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_security_hardening.py +0 -0
  55. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_snapshots.py +0 -0
  56. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_streaming.py +0 -0
  57. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_tokens.py +0 -0
  58. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_transcriber.py +0 -0
  59. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_tts.py +0 -0
  60. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_vocabulary.py +0 -0
  61. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_wizard.py +0 -0
  62. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/tests/test_wordfreq.py +0 -0
  63. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/__main__.py +0 -0
  64. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/audit.py +0 -0
  65. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/autocorrect.py +0 -0
  66. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/autocorrect_state.py +0 -0
  67. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/backends.py +0 -0
  68. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/cli.py +0 -0
  69. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/clipboard_read.py +0 -0
  70. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/commands.py +0 -0
  71. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/consent.py +0 -0
  72. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/corrections.py +0 -0
  73. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/demo.py +0 -0
  74. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/evaluate.py +0 -0
  75. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/feedback.py +0 -0
  76. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/health.py +0 -0
  77. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hints.py +0 -0
  78. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/history.py +0 -0
  79. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hotkeys/__init__.py +0 -0
  80. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hotkeys/base.py +0 -0
  81. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hotkeys/chain.py +0 -0
  82. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hotkeys/evdev.py +0 -0
  83. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hotkeys/pynput_backend.py +0 -0
  84. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/hotkeys/socket_backend.py +0 -0
  85. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/ibus/__init__.py +0 -0
  86. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/ibus/engine.py +0 -0
  87. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/ibus/pending.py +0 -0
  88. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/llm.py +0 -0
  89. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/llm_api.py +0 -0
  90. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/models/__init__.py +0 -0
  91. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/models/silero_vad.onnx +0 -0
  92. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/numbers.py +0 -0
  93. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/pidlock.py +0 -0
  94. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/platform.py +0 -0
  95. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/prompt.py +0 -0
  96. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/recorder.py +0 -0
  97. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/retention.py +0 -0
  98. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/service.py +0 -0
  99. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/snapshots.py +0 -0
  100. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/sounds/__init__.py +0 -0
  101. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/sounds/commit.wav +0 -0
  102. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/sounds/start.wav +0 -0
  103. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/sounds/stop.wav +0 -0
  104. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tokens.py +0 -0
  105. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/transcriber.py +0 -0
  106. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tray/__init__.py +0 -0
  107. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tray/_icons.py +0 -0
  108. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tray/_indicator.py +0 -0
  109. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tray/_pystray.py +0 -0
  110. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/__init__.py +0 -0
  111. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/base.py +0 -0
  112. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/chain.py +0 -0
  113. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/edge_engine.py +0 -0
  114. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/espeak.py +0 -0
  115. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/piper_engine.py +0 -0
  116. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/tts/player.py +0 -0
  117. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/__init__.py +0 -0
  118. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/base.py +0 -0
  119. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/chain.py +0 -0
  120. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/clipboard.py +0 -0
  121. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/ibus.py +0 -0
  122. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/pynput_type.py +0 -0
  123. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/wtype.py +0 -0
  124. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/xdotool.py +0 -0
  125. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/typers/ydotool.py +0 -0
  126. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/vocab_stats.py +0 -0
  127. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/vocabulary.py +0 -0
  128. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/wizard.py +0 -0
  129. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/wordfreq.py +0 -0
  130. {python_voiceio-0.9.4 → python_voiceio-0.9.6}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.4
3
+ Version: 0.9.6
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.4"
7
+ version = "0.9.6"
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.4
3
+ Version: 0.9.6
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,56 @@ 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_fix_mode_still_rejects_big_deletion():
125
+ """With disfluency mode OFF, the original conservative guards stand: a big
126
+ deletion is a length change and must be rejected."""
127
+ pc = PostCorrector(_cfg(remove_disfluencies=False))
128
+ original = "so um we should like ship the thing today"
129
+ cleaned = "so we should ship the thing today"
130
+ with patch("voiceio.llm_api.chat", return_value=cleaned):
131
+ assert pc.correct(original) == original
132
+
133
+
82
134
  def test_llm_error_returns_original():
83
135
  pc = PostCorrector(_cfg())
84
136
  with patch("voiceio.llm_api.chat", side_effect=RuntimeError("boom")):
@@ -1,10 +1,56 @@
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: an exact sentence repeated.
28
+ out = strip_disfluencies("Do the research. Do the research.")
29
+ assert out == "Do the research."
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_empty(self):
37
+ assert strip_disfluencies("") == ""
38
+
39
+
40
+ class TestPipelineDisfluencies:
41
+ def test_flag_off_keeps_fillers(self):
42
+ text, _ = apply_pipeline("um hello there friend", do_cleanup=True, final=True)
43
+ assert "um" in text.lower().split()
44
+
45
+ def test_flag_on_strips_fillers(self):
46
+ text, _ = apply_pipeline(
47
+ "um hello there friend",
48
+ do_cleanup=True, remove_disfluencies=True, final=True,
49
+ )
50
+ assert "um" not in text.lower().split()
51
+ assert "hello there friend" in text.lower()
52
+
53
+
8
54
  class TestCapitalization:
9
55
  def test_first_char(self):
10
56
  assert cleanup("hello world") == "Hello world"
@@ -51,16 +51,39 @@ class TestSileroVad:
51
51
  chunk = np.zeros(1024, dtype=np.float32)
52
52
  assert vad.is_speech(chunk) is False
53
53
 
54
- def test_speech_detected(self, vad):
55
- # Generate a sine wave at speech-like frequency
56
- t = np.linspace(0, 0.1, 1600, dtype=np.float32)
57
- chunk = 0.5 * np.sin(2 * np.pi * 300 * t)
58
- # Run several chunks to build state
59
- for _ in range(5):
60
- result = vad.is_speech(chunk)
61
- # At least one should detect speech (sine wave isn't silence)
62
- # Note: Silero may not classify pure sine as speech, so we just test it runs
63
- assert isinstance(result, bool)
54
+ def test_v5_feeds_context_window(self, vad):
55
+ """Regression: Silero v5 consumes 512+64=576 samples the window plus
56
+ the previous window's 64-sample tail as context. Feeding a bare 512
57
+ (the pre-fix bug) makes the model score all real speech as silence
58
+ (measured: max prob 0.016 vs 1.0 with context)."""
59
+ if not vad._use_state:
60
+ pytest.skip("v4 model has no context requirement")
61
+ seen = []
62
+ real_run = vad._session.run
63
+
64
+ def spy(outs, inputs):
65
+ seen.append(inputs["input"])
66
+ return real_run(outs, inputs)
67
+
68
+ vad._session.run = spy
69
+ # Two distinct windows so we can check the context carries across.
70
+ w1 = np.arange(512, dtype=np.float32) / 512.0
71
+ w2 = np.arange(512, 1024, dtype=np.float32) / 512.0
72
+ vad.is_speech(w1)
73
+ vad.is_speech(w2)
74
+ assert seen[0].shape[-1] == 512 + 64 # 576, not a bare 512
75
+ # First call's context is zeros; second call's leading 64 samples must
76
+ # be the tail of the first window (state carried between steps).
77
+ assert np.allclose(seen[0][0, :64], 0.0)
78
+ assert np.allclose(seen[1][0, :64], w1[-64:])
79
+
80
+ def test_reset_clears_context(self, vad):
81
+ if not vad._use_state:
82
+ pytest.skip("v4 model has no context")
83
+ vad.is_speech(np.arange(512, dtype=np.float32) / 512.0)
84
+ assert not np.allclose(vad._context, 0.0) # context now populated
85
+ vad.reset()
86
+ assert np.allclose(vad._context, 0.0)
64
87
 
65
88
  def test_reset_clears_state(self, vad):
66
89
  chunk = np.zeros(1024, dtype=np.float32)
@@ -0,0 +1 @@
1
+ __version__ = "0.9.6"
@@ -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,12 @@ 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
+ remove_disfluencies: bool = False
91
97
  voice_input_prefix: str = "" # e.g. "[voice]" — empty disables
92
98
  # Incremental finalization: once the un-finalized audio tail grows past
93
99
  # this many seconds, it is cut at the nearest interior speech pause,
@@ -26,6 +26,14 @@ 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
+
29
37
  _SYSTEM_PROMPT = (
30
38
  "You fix automatic speech recognition errors in dictated text. "
31
39
  "The user dictates about software engineering and their projects. "
@@ -35,6 +43,23 @@ _SYSTEM_PROMPT = (
35
43
  "Return only the corrected text with no commentary."
36
44
  )
37
45
 
46
+ # Disfluency mode: also strip spoken filler, delete-only. The strict rules
47
+ # mirror the guards — the model is told exactly what the diff check enforces.
48
+ _SYSTEM_PROMPT_CLEAN = (
49
+ "You convert dictated speech into clean written text. The user dictates "
50
+ "about software engineering and their projects. Do exactly two things:\n"
51
+ "1. Fix words the recognizer misheard (wrong proper nouns, homophones, "
52
+ "garbled technical terms).\n"
53
+ "2. Remove speech disfluencies: filler sounds (um, uh, er); filler uses of "
54
+ "'like', 'you know', 'I mean'; false starts and self-corrections (keep the "
55
+ "corrected version); and stray word repetitions.\n"
56
+ "STRICT RULES: Only DELETE disfluencies and FIX misheard words. NEVER add "
57
+ "words. NEVER rephrase, reword, reorder, or summarize. NEVER drop real "
58
+ "content, meaningful hedges, or negations. If unsure whether something is a "
59
+ "disfluency, KEEP it. Preserve the speaker's own wording and punctuation. "
60
+ "Return only the cleaned text, nothing else."
61
+ )
62
+
38
63
  _MAX_RECENT = 3
39
64
 
40
65
 
@@ -82,6 +107,7 @@ class PostCorrector:
82
107
  def __init__(self, cfg: Config):
83
108
  self._cfg = cfg
84
109
  self._pc = cfg.postcorrect
110
+ self._remove_disfluencies = cfg.output.remove_disfluencies
85
111
  # API key / base_url resolution is shared with [autocorrect].
86
112
  self._ac = cfg.autocorrect
87
113
  self._available: bool | None = None
@@ -210,10 +236,14 @@ class PostCorrector:
210
236
  t0 = time.monotonic()
211
237
  outcome: dict = {}
212
238
 
239
+ system_prompt = (
240
+ _SYSTEM_PROMPT_CLEAN if self._remove_disfluencies else _SYSTEM_PROMPT
241
+ )
242
+
213
243
  def _call() -> None:
214
244
  try:
215
245
  outcome["response"] = chat(
216
- self._client_cfg(), _SYSTEM_PROMPT, user_msg, max_tokens=1024,
246
+ self._client_cfg(), system_prompt, user_msg, max_tokens=1024,
217
247
  )
218
248
  except Exception as e:
219
249
  outcome["error"] = e
@@ -251,26 +281,47 @@ class PostCorrector:
251
281
  self._record(text, corrected, "unchanged")
252
282
  return text
253
283
 
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")
284
+ accept, reject_reason = self._guard(text, corrected)
285
+ if not accept:
286
+ log.debug("PostCorrector reject (%s) keeping original", reject_reason)
287
+ self._record(text, corrected, f"rejected_{reject_reason}")
272
288
  return text
273
289
 
274
290
  log.info("PostCorrector fixed: %s", ", ".join(_changed_words(text, corrected)))
275
291
  self._record(text, corrected, "applied")
276
292
  return corrected
293
+
294
+ def _guard(self, text: str, corrected: str) -> tuple[bool, str]:
295
+ """Decide whether the LLM's edit is within bounds.
296
+
297
+ Returns (accept, reject_reason). In disfluency mode the promise is
298
+ "never change meaning", enforced on the word-level diff: zero
299
+ insertions (nothing added/rephrased), few replacements (ASR fixes, not
300
+ rewording), bounded deletions (can't strip real content). Otherwise the
301
+ original conservative fix-only guards apply.
302
+ """
303
+ aw, bw = text.split(), corrected.split()
304
+ if not self._remove_disfluencies:
305
+ n = len(aw)
306
+ if n and abs(len(bw) - n) / n > _MAX_WORDCOUNT_DELTA:
307
+ return False, "wordcount"
308
+ if _word_edit_ratio(text, corrected) > _MAX_EDIT_RATIO:
309
+ return False, "editratio"
310
+ return True, ""
311
+
312
+ inserted = replaced = deleted = 0
313
+ for tag, i1, i2, j1, j2 in difflib.SequenceMatcher(a=aw, b=bw).get_opcodes():
314
+ if tag == "insert":
315
+ inserted += j2 - j1
316
+ elif tag == "replace":
317
+ replaced += max(i2 - i1, j2 - j1)
318
+ elif tag == "delete":
319
+ deleted += i2 - i1
320
+ n = len(aw)
321
+ if inserted > _MAX_INSERTED_WORDS:
322
+ return False, "inserted" # added content — meaning changed
323
+ if replaced > max(3, int(_MAX_REPLACE_FRAC * n)):
324
+ return False, "reworded" # too many substitutions = rewrite
325
+ if n and deleted > _MAX_DELETE_FRAC * n:
326
+ return False, "overdeleted" # nuked real content
327
+ return True, ""
@@ -13,6 +13,43 @@ if TYPE_CHECKING:
13
13
  # Languages that don't use letter casing
14
14
  _NO_CASE_LANGUAGES = frozenset({"zh", "ja", "ko", "ar", "he", "th", "hi", "bn", "ka", "my"})
15
15
 
16
+ # Filler SOUNDS only — tokens with no lexical meaning, so deleting them can
17
+ # never change meaning. Word repetitions ("had had" is valid English) and
18
+ # filler uses of "like"/"you know" need judgment and are left to the LLM layer.
19
+ # Consumes an adjacent comma on either side so "be, uh, found" → "be found".
20
+ _FILLER_RE = re.compile(
21
+ r"\s*,?\s*\b(?:u+m+|u+h+m*|e+r+m*|erm+|a+h+|h+m+|mm+|mhm|uh[-\s]?huh)\b\s*,?\s*",
22
+ re.IGNORECASE,
23
+ )
24
+
25
+
26
+ def strip_disfluencies(text: str) -> str:
27
+ """Delete-only, meaning-safe disfluency cleanup (regex layer).
28
+
29
+ Removes filler sounds (um, uh, er, …) and exact duplicate adjacent
30
+ sentences (a Whisper re-decode artifact). Only ever deletes — never
31
+ rephrases, reorders, or touches lexical words — so meaning is preserved by
32
+ construction. The judgment cases (false starts, filler "like", word
33
+ repetitions) are handled by the guarded LLM layer in postcorrect.
34
+ """
35
+ if not text:
36
+ return text
37
+ text = _FILLER_RE.sub(" ", text)
38
+ text = _dedup_adjacent_sentences(text)
39
+ text = re.sub(r"\s{2,}", " ", text)
40
+ return text.strip()
41
+
42
+
43
+ def _dedup_adjacent_sentences(text: str) -> str:
44
+ """Drop a sentence identical to the one immediately before it."""
45
+ parts = re.split(r"(?<=[.?!])\s+", text)
46
+ out: list[str] = []
47
+ for p in parts:
48
+ if out and p.strip().lower() == out[-1].strip().lower():
49
+ continue
50
+ out.append(p)
51
+ return " ".join(out)
52
+
16
53
 
17
54
  def cleanup(text: str, language: str = "en") -> str:
18
55
  """Clean up Whisper output. Idempotent — safe to run multiple times."""
@@ -50,6 +87,7 @@ def apply_pipeline(
50
87
  text: str,
51
88
  *,
52
89
  do_cleanup: bool = False,
90
+ remove_disfluencies: bool = False,
53
91
  number_conversion: bool = False,
54
92
  language: str = "en",
55
93
  commands: CommandProcessor | None = None,
@@ -64,6 +102,11 @@ def apply_pipeline(
64
102
  Returns (processed_text, abort). If abort is True, the caller should
65
103
  discard the result (e.g. undo/flag command was triggered).
66
104
  """
105
+ # Disfluency removal runs BEFORE cleanup so cleanup re-fixes the spacing,
106
+ # commas, and capitalization the deletions leave behind. Delete-only.
107
+ if remove_disfluencies:
108
+ text = strip_disfluencies(text)
109
+
67
110
  if do_cleanup:
68
111
  text = cleanup(text, language)
69
112
 
@@ -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,
@@ -16,6 +16,11 @@ log = logging.getLogger(__name__)
16
16
  _MODEL_PATH = Path(__file__).parent / "models" / "silero_vad.onnx"
17
17
  _WINDOW_SIZE = 512 # Silero expects 512 samples at 16kHz (~32ms)
18
18
  _SAMPLE_RATE = 16000
19
+ # Silero v5 consumes 576 samples per step: the 512-sample window PLUS the 64
20
+ # trailing samples of the previous window as context. Feeding a bare 512 makes
21
+ # the model score everything as non-speech (measured: max prob 0.016 vs 1.0
22
+ # with context). The official ONNX wrapper prepends this outside the graph.
23
+ _CONTEXT_SIZE = 64
19
24
 
20
25
 
21
26
  @runtime_checkable
@@ -46,6 +51,8 @@ class SileroVad:
46
51
  state_meta = [inp for inp in self._session.get_inputs() if inp.name == "state"][0]
47
52
  state_dim = state_meta.shape[2] # 128 for v5
48
53
  self._state = np.zeros((2, 1, state_dim), dtype=np.float32)
54
+ # v5 context: the previous window's trailing 64 samples.
55
+ self._context = np.zeros(_CONTEXT_SIZE, dtype=np.float32)
49
56
  else:
50
57
  self._h = np.zeros((2, 1, 64), dtype=np.float32)
51
58
  self._c = np.zeros((2, 1, 64), dtype=np.float32)
@@ -56,6 +63,7 @@ class SileroVad:
56
63
  def reset(self) -> None:
57
64
  if self._use_state:
58
65
  self._state = np.zeros_like(self._state)
66
+ self._context = np.zeros(_CONTEXT_SIZE, dtype=np.float32)
59
67
  else:
60
68
  self._h = np.zeros_like(self._h)
61
69
  self._c = np.zeros_like(self._c)
@@ -76,13 +84,15 @@ class SileroVad:
76
84
  return speech
77
85
 
78
86
  def _infer(self, window: np.ndarray) -> float:
79
- input_data = window.reshape(1, -1)
80
87
  sr = np.array(_SAMPLE_RATE, dtype=np.int64)
81
88
  if self._use_state:
82
- ort_inputs = {"input": input_data, "state": self._state, "sr": sr}
89
+ # Prepend the previous window's tail; the model needs 512+64=576.
90
+ x = np.concatenate([self._context, window])
91
+ self._context = window[-_CONTEXT_SIZE:].copy()
92
+ ort_inputs = {"input": x.reshape(1, -1), "state": self._state, "sr": sr}
83
93
  out, self._state = self._session.run(None, ort_inputs)
84
94
  else:
85
- ort_inputs = {"input": input_data, "h": self._h, "c": self._c, "sr": sr}
95
+ ort_inputs = {"input": window.reshape(1, -1), "h": self._h, "c": self._c, "sr": sr}
86
96
  out, self._h, self._c = self._session.run(None, ort_inputs)
87
97
  return float(out.squeeze())
88
98
 
@@ -1 +0,0 @@
1
- __version__ = "0.9.4"
File without changes
File without changes
File without changes