python-voiceio 0.9.10__tar.gz → 0.9.11__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.10/python_voiceio.egg-info → python_voiceio-0.9.11}/PKG-INFO +1 -1
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/pyproject.toml +1 -1
- {python_voiceio-0.9.10 → python_voiceio-0.9.11/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/SOURCES.txt +3 -0
- python_voiceio-0.9.11/tests/test_ibus_engine_limits.py +129 -0
- python_voiceio-0.9.11/tests/test_ibus_textlimit.py +94 -0
- python_voiceio-0.9.11/voiceio/__init__.py +1 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/ibus/engine.py +14 -7
- python_voiceio-0.9.11/voiceio/ibus/textlimit.py +66 -0
- python_voiceio-0.9.10/voiceio/__init__.py +0 -1
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/LICENSE +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/README.md +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/setup.cfg +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_audit.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_cli.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_commands.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_config.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_corrections.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_evaluate.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_fallback.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_health.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_hints.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_history.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_llm.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_numbers.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_platform.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_prompt.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_retention.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_robustness.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_streaming.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_tokens.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_tts.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_vad.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_wizard.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/__main__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/app.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/audit.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/backends.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/cli.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/commands.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/config.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/consent.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/corrections.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/demo.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/evaluate.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/feedback.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/health.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hints.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/history.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/llm.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/numbers.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/platform.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/prompt.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/recorder.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/retention.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/service.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/streaming.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tokens.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/vad.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/wizard.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.9.10 → python_voiceio-0.9.11}/voiceio/worker.py +0 -0
|
@@ -26,8 +26,10 @@ tests/test_fallback.py
|
|
|
26
26
|
tests/test_health.py
|
|
27
27
|
tests/test_hints.py
|
|
28
28
|
tests/test_history.py
|
|
29
|
+
tests/test_ibus_engine_limits.py
|
|
29
30
|
tests/test_ibus_pending.py
|
|
30
31
|
tests/test_ibus_ping.py
|
|
32
|
+
tests/test_ibus_textlimit.py
|
|
31
33
|
tests/test_ibus_typer.py
|
|
32
34
|
tests/test_llm.py
|
|
33
35
|
tests/test_llm_api.py
|
|
@@ -99,6 +101,7 @@ voiceio/hotkeys/socket_backend.py
|
|
|
99
101
|
voiceio/ibus/__init__.py
|
|
100
102
|
voiceio/ibus/engine.py
|
|
101
103
|
voiceio/ibus/pending.py
|
|
104
|
+
voiceio/ibus/textlimit.py
|
|
102
105
|
voiceio/models/__init__.py
|
|
103
106
|
voiceio/models/silero_vad.onnx
|
|
104
107
|
voiceio/sounds/__init__.py
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
"""Regression guard: the engine must never hand IBus an oversized string.
|
|
2
|
+
|
|
3
|
+
``test_ibus_textlimit`` proves the limiter itself is correct. This pins the
|
|
4
|
+
engine to actually routing through it, so the crash cannot come back via an
|
|
5
|
+
edit that bypasses the limiter. A stub stands in for the IBus GObject bindings,
|
|
6
|
+
which are a system package and are not installed in the test venv.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib
|
|
11
|
+
import sys
|
|
12
|
+
import types
|
|
13
|
+
|
|
14
|
+
import pytest
|
|
15
|
+
|
|
16
|
+
from voiceio.ibus.textlimit import MAX_TEXT_BYTES
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class _Text:
|
|
20
|
+
"""Stand-in for IBus.Text that just remembers its string."""
|
|
21
|
+
|
|
22
|
+
def __init__(self, string: str):
|
|
23
|
+
self.string = string
|
|
24
|
+
|
|
25
|
+
@staticmethod
|
|
26
|
+
def new_from_string(string: str) -> "_Text":
|
|
27
|
+
return _Text(string)
|
|
28
|
+
|
|
29
|
+
def append_attribute(self, *args) -> None:
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class _Recorder:
|
|
34
|
+
"""Stands in for ``self``, recording what the engine sends to IBus."""
|
|
35
|
+
|
|
36
|
+
def __init__(self):
|
|
37
|
+
self.committed: list[str] = []
|
|
38
|
+
self.preedits: list[str] = []
|
|
39
|
+
self.hides = 0
|
|
40
|
+
|
|
41
|
+
def hide_preedit_text(self) -> None:
|
|
42
|
+
self.hides += 1
|
|
43
|
+
|
|
44
|
+
def commit_text(self, text: _Text) -> None:
|
|
45
|
+
self.committed.append(text.string)
|
|
46
|
+
|
|
47
|
+
def update_preedit_text(self, text: _Text, cursor: int, visible: bool) -> None:
|
|
48
|
+
self.preedits.append(text.string)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
@pytest.fixture
|
|
52
|
+
def engine(monkeypatch):
|
|
53
|
+
ibus = types.SimpleNamespace(
|
|
54
|
+
Engine=type("Engine", (), {}),
|
|
55
|
+
Factory=type("Factory", (), {}),
|
|
56
|
+
Text=_Text,
|
|
57
|
+
AttrType=types.SimpleNamespace(UNDERLINE=1),
|
|
58
|
+
AttrUnderline=types.SimpleNamespace(SINGLE=1),
|
|
59
|
+
)
|
|
60
|
+
repository = types.ModuleType("gi.repository")
|
|
61
|
+
repository.IBus = ibus
|
|
62
|
+
repository.GLib = types.SimpleNamespace()
|
|
63
|
+
repository.GObject = types.SimpleNamespace()
|
|
64
|
+
gi = types.ModuleType("gi")
|
|
65
|
+
gi.require_version = lambda *a, **k: None
|
|
66
|
+
gi.repository = repository
|
|
67
|
+
|
|
68
|
+
monkeypatch.setitem(sys.modules, "gi", gi)
|
|
69
|
+
monkeypatch.setitem(sys.modules, "gi.repository", repository)
|
|
70
|
+
sys.modules.pop("voiceio.ibus.engine", None)
|
|
71
|
+
module = importlib.import_module("voiceio.ibus.engine")
|
|
72
|
+
yield module.VoiceIOEngine
|
|
73
|
+
sys.modules.pop("voiceio.ibus.engine", None)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
@pytest.fixture
|
|
77
|
+
def long_note() -> str:
|
|
78
|
+
"""A transcript the size of the ones that crashed Ghostty and Obsidian."""
|
|
79
|
+
return " ".join(f"word{i}" for i in range(900))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _nbytes(text: str) -> int:
|
|
83
|
+
return len(text.encode("utf-8"))
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_commit_chunks_stay_under_the_limit(engine, long_note):
|
|
87
|
+
rec = _Recorder()
|
|
88
|
+
engine.commit(rec, long_note)
|
|
89
|
+
assert len(rec.committed) > 1
|
|
90
|
+
assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in rec.committed)
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def test_commit_delivers_the_whole_note(engine, long_note):
|
|
94
|
+
rec = _Recorder()
|
|
95
|
+
engine.commit(rec, long_note)
|
|
96
|
+
assert "".join(rec.committed) == long_note
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def test_commit_hides_preedit_first(engine, long_note):
|
|
100
|
+
rec = _Recorder()
|
|
101
|
+
engine.commit(rec, long_note)
|
|
102
|
+
assert rec.hides == 1
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_empty_commit_sends_nothing(engine):
|
|
106
|
+
rec = _Recorder()
|
|
107
|
+
engine.commit(rec, "")
|
|
108
|
+
assert rec.committed == []
|
|
109
|
+
assert rec.hides == 1
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def test_preedit_stays_under_the_limit(engine, long_note):
|
|
113
|
+
rec = _Recorder()
|
|
114
|
+
engine.preedit(rec, long_note)
|
|
115
|
+
assert len(rec.preedits) == 1
|
|
116
|
+
assert _nbytes(rec.preedits[0]) <= MAX_TEXT_BYTES
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_short_preedit_is_shown_verbatim(engine):
|
|
120
|
+
rec = _Recorder()
|
|
121
|
+
engine.preedit(rec, "hello there")
|
|
122
|
+
assert rec.preedits == ["hello there"]
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_empty_preedit_hides_instead(engine):
|
|
126
|
+
rec = _Recorder()
|
|
127
|
+
engine.preedit(rec, "")
|
|
128
|
+
assert rec.preedits == []
|
|
129
|
+
assert rec.hides == 1
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
"""Tests for the IBus text size limits.
|
|
2
|
+
|
|
3
|
+
The bug these guard against: a preedit or commit string above libwayland's
|
|
4
|
+
4096-byte per-message buffer makes the compositor drop the focused
|
|
5
|
+
application's connection, killing it mid-dictation.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from voiceio.ibus.textlimit import (
|
|
12
|
+
MAX_TEXT_BYTES,
|
|
13
|
+
TRUNCATION_MARK,
|
|
14
|
+
split_utf8,
|
|
15
|
+
tail_utf8,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
# The real 4096-byte ceiling every chunk has to clear, with room for the
|
|
19
|
+
# neighbouring events GNOME Shell may pack into the same buffer flush.
|
|
20
|
+
WAYLAND_BUFFER_BYTES = 4096
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _nbytes(text: str) -> int:
|
|
24
|
+
return len(text.encode("utf-8"))
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@pytest.fixture
|
|
28
|
+
def long_note() -> str:
|
|
29
|
+
"""A transcript the size of the ones that crashed Ghostty and Obsidian."""
|
|
30
|
+
return " ".join(f"word{i}" for i in range(900))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_short_text_is_one_chunk():
|
|
34
|
+
assert split_utf8("hello there") == ["hello there"]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def test_empty_text_yields_no_chunks():
|
|
38
|
+
assert split_utf8("") == []
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_chunks_rejoin_to_the_original(long_note):
|
|
42
|
+
assert "".join(split_utf8(long_note)) == long_note
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_every_chunk_fits_the_wayland_buffer(long_note):
|
|
46
|
+
chunks = split_utf8(long_note)
|
|
47
|
+
assert len(chunks) > 1
|
|
48
|
+
assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in chunks)
|
|
49
|
+
assert all(_nbytes(c) < WAYLAND_BUFFER_BYTES for c in chunks)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def test_multibyte_characters_are_never_cut_in_half():
|
|
53
|
+
# No spaces, so splitting has to fall back to character boundaries.
|
|
54
|
+
text = "é€𝄞" * 2000
|
|
55
|
+
chunks = split_utf8(text)
|
|
56
|
+
assert "".join(chunks) == text
|
|
57
|
+
assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in chunks)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_splits_prefer_word_boundaries(long_note):
|
|
61
|
+
# Every chunk but the last should end at a space, so commits do not land
|
|
62
|
+
# mid-word in editors that treat each commit as a unit.
|
|
63
|
+
chunks = split_utf8(long_note)
|
|
64
|
+
assert all(c.endswith(" ") for c in chunks[:-1])
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def test_a_run_without_spaces_still_splits():
|
|
68
|
+
text = "x" * (MAX_TEXT_BYTES * 3)
|
|
69
|
+
chunks = split_utf8(text)
|
|
70
|
+
assert "".join(chunks) == text
|
|
71
|
+
assert all(_nbytes(c) <= MAX_TEXT_BYTES for c in chunks)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def test_short_preedit_is_untouched():
|
|
75
|
+
assert tail_utf8("hello there") == "hello there"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def test_long_preedit_is_bounded_and_marked(long_note):
|
|
79
|
+
shown = tail_utf8(long_note)
|
|
80
|
+
assert _nbytes(shown) <= MAX_TEXT_BYTES
|
|
81
|
+
assert shown.startswith(TRUNCATION_MARK)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_preedit_keeps_the_most_recent_words(long_note):
|
|
85
|
+
# The tail is what the speaker is saying right now — that is the half worth
|
|
86
|
+
# showing when the preview has to be trimmed.
|
|
87
|
+
assert tail_utf8(long_note).endswith(long_note[-200:])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_preedit_tail_survives_multibyte_text():
|
|
91
|
+
text = "é" * 4000
|
|
92
|
+
shown = tail_utf8(text)
|
|
93
|
+
assert _nbytes(shown) <= MAX_TEXT_BYTES
|
|
94
|
+
assert shown == TRUNCATION_MARK + "é" * ((MAX_TEXT_BYTES - _nbytes(TRUNCATION_MARK)) // 2)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.11"
|
|
@@ -30,6 +30,7 @@ from voiceio.ibus import (
|
|
|
30
30
|
acquire_singleton_lock,
|
|
31
31
|
)
|
|
32
32
|
from voiceio.ibus.pending import PendingBuffer
|
|
33
|
+
from voiceio.ibus.textlimit import split_utf8, tail_utf8
|
|
33
34
|
|
|
34
35
|
log = logging.getLogger(__name__)
|
|
35
36
|
|
|
@@ -38,6 +39,11 @@ log = logging.getLogger(__name__)
|
|
|
38
39
|
_lock_fd: int | None = None
|
|
39
40
|
ENGINE_NAME = "voiceio"
|
|
40
41
|
COMPONENT_NAME = "org.voiceio.ibus"
|
|
42
|
+
# A datagram longer than the buffer we hand recvfrom() is truncated silently,
|
|
43
|
+
# which would inject half a dictation with no error anywhere. Sized above the
|
|
44
|
+
# largest datagram a sender can produce (SO_SNDBUF, ~208K by default) so that
|
|
45
|
+
# cannot happen.
|
|
46
|
+
RECV_BYTES = 262144
|
|
41
47
|
|
|
42
48
|
|
|
43
49
|
class VoiceIOEngine(IBus.Engine):
|
|
@@ -69,24 +75,25 @@ class VoiceIOEngine(IBus.Engine):
|
|
|
69
75
|
return False
|
|
70
76
|
|
|
71
77
|
def preedit(self, text: str) -> None:
|
|
72
|
-
"""Show text as preedit (underlined preview)."""
|
|
78
|
+
"""Show text as preedit (underlined preview), tail-first once long."""
|
|
73
79
|
if not text:
|
|
74
80
|
self.hide_preedit_text()
|
|
75
81
|
return
|
|
76
|
-
|
|
82
|
+
shown = tail_utf8(text)
|
|
83
|
+
ibus_text = IBus.Text.new_from_string(shown)
|
|
77
84
|
ibus_text.append_attribute(
|
|
78
85
|
IBus.AttrType.UNDERLINE,
|
|
79
86
|
IBus.AttrUnderline.SINGLE,
|
|
80
87
|
0,
|
|
81
|
-
len(
|
|
88
|
+
len(shown),
|
|
82
89
|
)
|
|
83
|
-
self.update_preedit_text(ibus_text, len(
|
|
90
|
+
self.update_preedit_text(ibus_text, len(shown), True)
|
|
84
91
|
|
|
85
92
|
def commit(self, text: str) -> None:
|
|
86
93
|
"""Clear preedit and commit final text."""
|
|
87
94
|
self.hide_preedit_text()
|
|
88
|
-
|
|
89
|
-
self.commit_text(IBus.Text.new_from_string(
|
|
95
|
+
for chunk in split_utf8(text):
|
|
96
|
+
self.commit_text(IBus.Text.new_from_string(chunk))
|
|
90
97
|
|
|
91
98
|
def clear(self) -> None:
|
|
92
99
|
"""Clear preedit without committing."""
|
|
@@ -150,7 +157,7 @@ def _socket_listener(mainloop: GLib.MainLoop) -> None:
|
|
|
150
157
|
|
|
151
158
|
while mainloop.is_running():
|
|
152
159
|
try:
|
|
153
|
-
data, addr = sock.recvfrom(
|
|
160
|
+
data, addr = sock.recvfrom(RECV_BYTES)
|
|
154
161
|
except socket.timeout:
|
|
155
162
|
continue
|
|
156
163
|
except OSError:
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""Size limits for text handed to IBus.
|
|
2
|
+
|
|
3
|
+
libwayland gives every client connection a fixed 4096-byte message buffer.
|
|
4
|
+
GNOME Shell relays our preedit and commit strings to the focused application as
|
|
5
|
+
single ``zwp_text_input_v3`` events, so a string that does not fit makes the
|
|
6
|
+
compositor tear down that application's connection — it dies mid-dictation,
|
|
7
|
+
whatever it happens to be. Shell can pack several events into one flush, so the
|
|
8
|
+
budget here leaves real headroom rather than sitting just under 4096.
|
|
9
|
+
|
|
10
|
+
Kept free of any ``gi`` dependency so it can be unit-tested without the IBus
|
|
11
|
+
GObject bindings installed.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
MAX_TEXT_BYTES = 2048
|
|
16
|
+
TRUNCATION_MARK = "… "
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _is_continuation(byte: int) -> bool:
|
|
20
|
+
return byte & 0xC0 == 0x80
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def split_utf8(text: str, limit: int = MAX_TEXT_BYTES) -> list[str]:
|
|
24
|
+
"""Split ``text`` into chunks of at most ``limit`` UTF-8 bytes.
|
|
25
|
+
|
|
26
|
+
The chunks concatenate back to exactly ``text``, so committing them in order
|
|
27
|
+
is indistinguishable to the target application from a single commit. A split
|
|
28
|
+
lands on a space when one falls in the back half of the chunk, and on a
|
|
29
|
+
character boundary otherwise, so no multi-byte character is ever cut in half.
|
|
30
|
+
"""
|
|
31
|
+
data = text.encode("utf-8")
|
|
32
|
+
if len(data) <= limit:
|
|
33
|
+
return [text] if text else []
|
|
34
|
+
|
|
35
|
+
chunks: list[str] = []
|
|
36
|
+
start = 0
|
|
37
|
+
while start < len(data):
|
|
38
|
+
end = min(start + limit, len(data))
|
|
39
|
+
if end < len(data):
|
|
40
|
+
space = data.rfind(b" ", start + limit // 2, end)
|
|
41
|
+
if space != -1:
|
|
42
|
+
end = space + 1
|
|
43
|
+
else:
|
|
44
|
+
while end > start and _is_continuation(data[end]):
|
|
45
|
+
end -= 1
|
|
46
|
+
chunks.append(data[start:end].decode("utf-8"))
|
|
47
|
+
start = end
|
|
48
|
+
return chunks
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def tail_utf8(text: str, limit: int = MAX_TEXT_BYTES) -> str:
|
|
52
|
+
"""Return the trailing ``limit`` UTF-8 bytes of ``text``, marked if trimmed.
|
|
53
|
+
|
|
54
|
+
The preedit preview is one replaceable string rather than an append, so it
|
|
55
|
+
cannot be chunked the way a commit can — it has to be bounded instead. Only
|
|
56
|
+
the preview is shortened; the full text still reaches the application when
|
|
57
|
+
the dictation commits.
|
|
58
|
+
"""
|
|
59
|
+
data = text.encode("utf-8")
|
|
60
|
+
if len(data) <= limit:
|
|
61
|
+
return text
|
|
62
|
+
|
|
63
|
+
start = len(data) - (limit - len(TRUNCATION_MARK.encode("utf-8")))
|
|
64
|
+
while start < len(data) and _is_continuation(data[start]):
|
|
65
|
+
start += 1
|
|
66
|
+
return TRUNCATION_MARK + data[start:].decode("utf-8")
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.10"
|
|
File without changes
|
|
File without changes
|
{python_voiceio-0.9.10 → python_voiceio-0.9.11}/python_voiceio.egg-info/dependency_links.txt
RENAMED
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|