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.
- {python_voiceio-0.9.5/python_voiceio.egg-info → python_voiceio-0.9.7}/PKG-INFO +1 -1
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/pyproject.toml +1 -1
- {python_voiceio-0.9.5 → python_voiceio-0.9.7/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_postcorrect.py +72 -1
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_postprocess.py +80 -1
- python_voiceio-0.9.7/voiceio/__init__.py +1 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/app.py +3 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/config.py +7 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/postcorrect.py +87 -19
- python_voiceio-0.9.7/voiceio/postprocess.py +164 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/streaming.py +3 -0
- python_voiceio-0.9.5/voiceio/__init__.py +0 -1
- python_voiceio-0.9.5/voiceio/postprocess.py +0 -95
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/LICENSE +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/README.md +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/setup.cfg +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_audit.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_cli.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_commands.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_config.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_corrections.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_evaluate.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_fallback.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_health.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_hints.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_history.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_llm.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_numbers.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_platform.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_prompt.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_retention.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_robustness.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_streaming.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_tokens.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_tts.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_vad.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_wizard.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/__main__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/audit.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/backends.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/cli.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/commands.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/consent.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/corrections.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/demo.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/evaluate.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/feedback.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/health.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hints.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/history.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/llm.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/numbers.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/platform.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/prompt.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/recorder.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/retention.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/service.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tokens.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/vad.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/wizard.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.9.5 → python_voiceio-0.9.7}/voiceio/worker.py +0 -0
|
@@ -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=""
|
|
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(),
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|