python-voiceio 0.9.1__tar.gz → 0.9.3__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 (132) hide show
  1. {python_voiceio-0.9.1/python_voiceio.egg-info → python_voiceio-0.9.3}/PKG-INFO +1 -1
  2. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/pyproject.toml +1 -1
  3. {python_voiceio-0.9.1 → python_voiceio-0.9.3/python_voiceio.egg-info}/PKG-INFO +1 -1
  4. python_voiceio-0.9.3/tests/test_ibus_ping.py +113 -0
  5. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_robustness.py +73 -0
  6. python_voiceio-0.9.3/voiceio/__init__.py +1 -0
  7. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/app.py +22 -2
  8. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/config.py +5 -0
  9. python_voiceio-0.9.3/voiceio/ibus/__init__.py +64 -0
  10. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/ibus/engine.py +20 -8
  11. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/recorder.py +22 -3
  12. python_voiceio-0.9.1/tests/test_ibus_ping.py +0 -56
  13. python_voiceio-0.9.1/voiceio/__init__.py +0 -1
  14. python_voiceio-0.9.1/voiceio/ibus/__init__.py +0 -33
  15. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/LICENSE +0 -0
  16. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/README.md +0 -0
  17. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/SOURCES.txt +0 -0
  18. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
  19. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/entry_points.txt +0 -0
  20. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/requires.txt +0 -0
  21. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/top_level.txt +0 -0
  22. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/setup.cfg +0 -0
  23. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_adjudicate.py +0 -0
  24. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_app_wiring.py +0 -0
  25. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_audio_quality.py +0 -0
  26. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_audit.py +0 -0
  27. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_autocorrect.py +0 -0
  28. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_autocorrect_state.py +0 -0
  29. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_backend_probes.py +0 -0
  30. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_cli.py +0 -0
  31. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_clipboard_read.py +0 -0
  32. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_commands.py +0 -0
  33. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_concurrency_lockdown.py +0 -0
  34. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_config.py +0 -0
  35. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_correct_batch.py +0 -0
  36. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_corrections.py +0 -0
  37. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_evaluate.py +0 -0
  38. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_fallback.py +0 -0
  39. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_health.py +0 -0
  40. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_hints.py +0 -0
  41. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_history.py +0 -0
  42. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_ibus_pending.py +0 -0
  43. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_ibus_typer.py +0 -0
  44. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_llm.py +0 -0
  45. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_llm_api.py +0 -0
  46. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_numbers.py +0 -0
  47. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_platform.py +0 -0
  48. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_postcorrect.py +0 -0
  49. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_postprocess.py +0 -0
  50. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_prebuffer.py +0 -0
  51. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_prompt.py +0 -0
  52. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_recorder_integration.py +0 -0
  53. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_retention.py +0 -0
  54. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_security_hardening.py +0 -0
  55. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_snapshots.py +0 -0
  56. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_streaming.py +0 -0
  57. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_tokens.py +0 -0
  58. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_transcriber.py +0 -0
  59. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_tts.py +0 -0
  60. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_vad.py +0 -0
  61. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_vocabulary.py +0 -0
  62. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_wizard.py +0 -0
  63. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_wordfreq.py +0 -0
  64. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/__main__.py +0 -0
  65. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/audit.py +0 -0
  66. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/autocorrect.py +0 -0
  67. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/autocorrect_state.py +0 -0
  68. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/backends.py +0 -0
  69. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/cli.py +0 -0
  70. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/clipboard_read.py +0 -0
  71. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/commands.py +0 -0
  72. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/consent.py +0 -0
  73. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/corrections.py +0 -0
  74. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/demo.py +0 -0
  75. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/evaluate.py +0 -0
  76. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/feedback.py +0 -0
  77. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/health.py +0 -0
  78. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hints.py +0 -0
  79. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/history.py +0 -0
  80. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/__init__.py +0 -0
  81. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/base.py +0 -0
  82. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/chain.py +0 -0
  83. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/evdev.py +0 -0
  84. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/pynput_backend.py +0 -0
  85. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/socket_backend.py +0 -0
  86. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/ibus/pending.py +0 -0
  87. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/llm.py +0 -0
  88. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/llm_api.py +0 -0
  89. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/models/__init__.py +0 -0
  90. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/models/silero_vad.onnx +0 -0
  91. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/numbers.py +0 -0
  92. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/pidlock.py +0 -0
  93. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/platform.py +0 -0
  94. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/postcorrect.py +0 -0
  95. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/postprocess.py +0 -0
  96. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/prompt.py +0 -0
  97. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/retention.py +0 -0
  98. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/service.py +0 -0
  99. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/snapshots.py +0 -0
  100. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/__init__.py +0 -0
  101. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/commit.wav +0 -0
  102. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/start.wav +0 -0
  103. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/stop.wav +0 -0
  104. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/streaming.py +0 -0
  105. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tokens.py +0 -0
  106. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/transcriber.py +0 -0
  107. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/__init__.py +0 -0
  108. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/_icons.py +0 -0
  109. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/_indicator.py +0 -0
  110. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/_pystray.py +0 -0
  111. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/__init__.py +0 -0
  112. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/base.py +0 -0
  113. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/chain.py +0 -0
  114. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/edge_engine.py +0 -0
  115. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/espeak.py +0 -0
  116. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/piper_engine.py +0 -0
  117. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/player.py +0 -0
  118. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/__init__.py +0 -0
  119. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/base.py +0 -0
  120. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/chain.py +0 -0
  121. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/clipboard.py +0 -0
  122. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/ibus.py +0 -0
  123. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/pynput_type.py +0 -0
  124. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/wtype.py +0 -0
  125. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/xdotool.py +0 -0
  126. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/ydotool.py +0 -0
  127. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/vad.py +0 -0
  128. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/vocab_stats.py +0 -0
  129. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/vocabulary.py +0 -0
  130. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/wizard.py +0 -0
  131. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/wordfreq.py +0 -0
  132. {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/worker.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-voiceio
3
- Version: 0.9.1
3
+ Version: 0.9.3
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.1"
7
+ version = "0.9.3"
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.1
3
+ Version: 0.9.3
4
4
  Summary: Voice dictation for Linux. Speak → text, locally, instantly.
5
5
  Author: Hugo Montenegro
6
6
  License-Expression: MIT
@@ -0,0 +1,113 @@
1
+ """Tests for the shared engine ping used by the daemon health watchdog and
2
+ the engine's own refuse-to-double-run guard (a duplicate engine binding the
3
+ socket path silently starves the healthy instance)."""
4
+ from __future__ import annotations
5
+
6
+ import os
7
+ import socket
8
+ import sys
9
+ import threading
10
+
11
+ import pytest
12
+
13
+ import voiceio.ibus as ibus_mod
14
+
15
+ # IBus (and AF_UNIX DGRAM + Linux abstract autobind) is Linux-only.
16
+ pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="IBus is Linux-only")
17
+
18
+
19
+ def test_no_socket_returns_false(tmp_path, monkeypatch):
20
+ monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
21
+ assert ibus_mod.ping_engine(timeout=0.2) is False
22
+
23
+
24
+ def test_healthy_peer_answers_pong(tmp_path, monkeypatch):
25
+ sock_path = tmp_path / "engine.sock"
26
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
27
+ server.bind(str(sock_path))
28
+ server.settimeout(2.0)
29
+
30
+ def _serve():
31
+ try:
32
+ data, addr = server.recvfrom(64)
33
+ if data == b"ping" and addr:
34
+ server.sendto(b"pong", addr)
35
+ except OSError:
36
+ pass
37
+
38
+ t = threading.Thread(target=_serve, daemon=True)
39
+ t.start()
40
+ monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
41
+ try:
42
+ assert ibus_mod.ping_engine(timeout=1.0) is True
43
+ finally:
44
+ t.join(timeout=2.0)
45
+ server.close()
46
+
47
+
48
+ def test_unresponsive_socket_returns_false(tmp_path, monkeypatch):
49
+ """A bound-but-silent socket (starved listener) must read as dead."""
50
+ sock_path = tmp_path / "engine.sock"
51
+ server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
52
+ server.bind(str(sock_path))
53
+ monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
54
+ try:
55
+ assert ibus_mod.ping_engine(timeout=0.2) is False
56
+ finally:
57
+ server.close()
58
+
59
+
60
+ class TestSingletonLock:
61
+ """The atomic gate that stops a duplicate engine from stealing the socket.
62
+
63
+ ibus-daemon exec-spawns its own engine copy when the voiceio source is
64
+ activated; only the flock holder may bind the command socket, so the loser
65
+ exits instead of unlink+rebind-ing it into a zombie.
66
+ """
67
+
68
+ def test_second_acquire_fails_while_held(self, tmp_path, monkeypatch):
69
+ monkeypatch.setattr(ibus_mod, "LOCK_PATH", tmp_path / "e.lock")
70
+ fd = ibus_mod.acquire_singleton_lock()
71
+ assert fd is not None
72
+ try:
73
+ # A concurrent engine (same process here, standing in for a second
74
+ # process on the same file) must be refused.
75
+ assert ibus_mod.acquire_singleton_lock() is None
76
+ finally:
77
+ os.close(fd)
78
+
79
+ def test_reacquire_succeeds_after_holder_closes(self, tmp_path, monkeypatch):
80
+ """Releasing the fd (process death) frees the lock for the next engine."""
81
+ monkeypatch.setattr(ibus_mod, "LOCK_PATH", tmp_path / "e.lock")
82
+ fd = ibus_mod.acquire_singleton_lock()
83
+ assert fd is not None
84
+ os.close(fd) # simulate the holder dying
85
+ fd2 = ibus_mod.acquire_singleton_lock()
86
+ assert fd2 is not None
87
+ os.close(fd2)
88
+
89
+ def test_lock_is_cross_process(self, tmp_path, monkeypatch):
90
+ """flock must exclude a genuinely separate process, not just this one."""
91
+ import subprocess
92
+ import textwrap
93
+
94
+ lock_path = tmp_path / "e.lock"
95
+ monkeypatch.setattr(ibus_mod, "LOCK_PATH", lock_path)
96
+ fd = ibus_mod.acquire_singleton_lock()
97
+ assert fd is not None
98
+ try:
99
+ # A child process using the same flock path must be refused while we
100
+ # hold it. Exit code 7 = "got the lock" (bad), 0 = "refused" (good).
101
+ code = textwrap.dedent(f"""
102
+ import fcntl, os, sys
103
+ fd = os.open({str(lock_path)!r}, os.O_CREAT | os.O_RDWR, 0o600)
104
+ try:
105
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
106
+ sys.exit(7)
107
+ except OSError:
108
+ sys.exit(0)
109
+ """)
110
+ r = subprocess.run([sys.executable, "-c", code], timeout=5)
111
+ assert r.returncode == 0
112
+ finally:
113
+ os.close(fd)
@@ -63,6 +63,7 @@ def _make_recorder():
63
63
  cfg.silence_threshold = 0.01
64
64
  cfg.silence_duration = 0.6
65
65
  cfg.auto_stop_silence_secs = 5.0
66
+ cfg.auto_stop_no_speech_secs = 20.0
66
67
 
67
68
  with patch("voiceio.recorder.sd"):
68
69
  from voiceio.recorder import AudioRecorder
@@ -72,6 +73,78 @@ def _make_recorder():
72
73
  return rec
73
74
 
74
75
 
76
+ class TestAutoStop:
77
+ """Auto-stop distinguishes 'spoke then paused' from 'nothing ever heard'."""
78
+
79
+ def _feed(self, rec, secs, *, speech):
80
+ """Drive _callback with `secs` of audio; speech=False → silence."""
81
+ rec._vad.is_speech.return_value = speech
82
+ frames = int(secs * rec.sample_rate)
83
+ rec._callback(np.zeros((frames, 1), dtype=np.float32), frames, None, None)
84
+
85
+ def test_no_speech_fires_no_speech_reason(self):
86
+ rec = _make_recorder()
87
+ rec._recording = True
88
+ fired = []
89
+ rec.set_on_auto_stop(lambda reason: fired.append(reason))
90
+ # 21s of pure silence, speech never heard.
91
+ self._feed(rec, 21, speech=False)
92
+ assert fired == ["no_speech"]
93
+
94
+ def test_no_speech_does_not_fire_early(self):
95
+ rec = _make_recorder()
96
+ rec._recording = True
97
+ fired = []
98
+ rec.set_on_auto_stop(lambda reason: fired.append(reason))
99
+ self._feed(rec, 10, speech=False) # below the 20s threshold
100
+ assert fired == []
101
+
102
+ def test_speech_then_silence_fires_silence_reason(self):
103
+ rec = _make_recorder()
104
+ rec._recording = True
105
+ fired = []
106
+ rec.set_on_auto_stop(lambda reason: fired.append(reason))
107
+ self._feed(rec, 1, speech=True) # heard speech → _heard_speech
108
+ self._feed(rec, 6, speech=False) # 6s silence ≥ 5s auto_stop
109
+ assert fired == ["silence"]
110
+
111
+ def test_single_fire(self):
112
+ """Callback is cleared on fire — a later chunk must not re-trigger."""
113
+ rec = _make_recorder()
114
+ rec._recording = True
115
+ fired = []
116
+ rec.set_on_auto_stop(lambda reason: fired.append(reason))
117
+ self._feed(rec, 21, speech=False)
118
+ self._feed(rec, 21, speech=False)
119
+ assert fired == ["no_speech"]
120
+
121
+
122
+ class TestAutoStopNotification:
123
+ """The app surfaces a 'no_speech' auto-stop; a plain 'silence' one is quiet."""
124
+
125
+ def test_no_speech_muted_mic_notifies(self):
126
+ vio, _, _ = _make_vio()
127
+ vio.recorder.has_signal = MagicMock(return_value=False) # all zeros = muted
128
+ with patch("voiceio.feedback.notify") as notify:
129
+ vio._on_auto_stop("no_speech")
130
+ assert notify.called
131
+ assert "mute" in " ".join(notify.call_args[0]).lower()
132
+
133
+ def test_no_speech_with_signal_notifies_generic(self):
134
+ vio, _, _ = _make_vio()
135
+ vio.recorder.has_signal = MagicMock(return_value=True) # mic works, user quiet
136
+ with patch("voiceio.feedback.notify") as notify:
137
+ vio._on_auto_stop("no_speech")
138
+ assert notify.called
139
+ assert "mute" not in " ".join(notify.call_args[0]).lower()
140
+
141
+ def test_normal_silence_is_quiet(self):
142
+ vio, _, _ = _make_vio()
143
+ with patch("voiceio.feedback.notify") as notify:
144
+ vio._on_auto_stop("silence")
145
+ assert not notify.called
146
+
147
+
75
148
  # ===========================================================================
76
149
  # 1. stream_health() tests
77
150
  # ===========================================================================
@@ -0,0 +1 @@
1
+ __version__ = "0.9.3"
@@ -283,8 +283,28 @@ class VoiceIO:
283
283
  self._last_hotkey = now
284
284
  self._toggle()
285
285
 
286
- def _on_auto_stop(self) -> None:
287
- """Called from audio thread when sustained silence triggers auto-stop."""
286
+ def _on_auto_stop(self, reason: str = "silence") -> None:
287
+ """Called from the audio thread when the recorder auto-stops.
288
+
289
+ reason="silence": spoke, then went quiet — a normal end of dictation.
290
+ reason="no_speech": nothing was heard the whole time — a muted mic or a
291
+ hotkey pressed by accident. Surface it so it isn't a silent failure
292
+ (today's Discord-mute confusion: recording ran, nothing was captured,
293
+ and there was no sign why).
294
+ """
295
+ if reason == "no_speech":
296
+ from voiceio import feedback
297
+ if not self.recorder.has_signal():
298
+ feedback.notify(
299
+ "VoiceIO: microphone is muted",
300
+ "Stopped — no audio is reaching voiceio. Check your mic's "
301
+ "mute button and that the right input device is selected.",
302
+ )
303
+ else:
304
+ feedback.notify(
305
+ "VoiceIO: stopped — no speech detected",
306
+ "Recording auto-stopped after silence.",
307
+ )
288
308
  threading.Thread(target=self._request_stop, daemon=True).start()
289
309
 
290
310
  def _request_stop(self) -> None:
@@ -71,6 +71,11 @@ class AudioConfig:
71
71
  silence_threshold: float = 0.01
72
72
  silence_duration: float = 0.6
73
73
  auto_stop_silence_secs: float = 5.0
74
+ # Safety net for a muted mic or a forgotten hotkey: if NO speech is heard
75
+ # for this long from the start, stop and notify. The 5s auto-stop above
76
+ # only fires once speech has been heard, so it can't catch a dead mic.
77
+ # 0 disables.
78
+ auto_stop_no_speech_secs: float = 20.0
74
79
  vad_backend: str = "silero" # "silero" | "rms"
75
80
  vad_threshold: float = 0.5 # Silero speech probability threshold
76
81
 
@@ -0,0 +1,64 @@
1
+ """IBus input method engine for VoiceIO."""
2
+ from __future__ import annotations
3
+
4
+ import os
5
+ from pathlib import Path
6
+
7
+ SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
8
+ READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
9
+ # Held for the engine's lifetime so exactly one process ever owns the command
10
+ # socket — see acquire_singleton_lock().
11
+ LOCK_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.lock"
12
+
13
+
14
+ def acquire_singleton_lock() -> int | None:
15
+ """Take an exclusive, lifetime-held lock on LOCK_PATH.
16
+
17
+ Returns the open fd on success (the caller must keep it open — the lock is
18
+ released when the fd closes, i.e. when the process dies), or None if another
19
+ engine already holds it.
20
+
21
+ This is the race-free gate against duplicate engines. ibus-daemon
22
+ exec-spawns its own copy of the engine when something activates the voiceio
23
+ source (GNOME's per-window source memory / Super+Space — voiceio sits first
24
+ in mru-sources). A ping-then-bind check is check-then-act: two engines
25
+ starting close together both see "no healthy peer", then both unlink+rebind
26
+ the DGRAM socket in _socket_listener. The loser is left bound to an orphaned
27
+ inode — alive but never receiving pings ("zombie") — which the daemon's
28
+ health watchdog then kills and respawns, churning ~15x/day. flock is atomic:
29
+ the loser never touches the socket, it just exits.
30
+ """
31
+ import fcntl
32
+
33
+ fd = os.open(str(LOCK_PATH), os.O_CREAT | os.O_RDWR, 0o600)
34
+ try:
35
+ fcntl.flock(fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
36
+ except OSError:
37
+ os.close(fd)
38
+ return None
39
+ return fd
40
+
41
+
42
+ def ping_engine(timeout: float = 2.0) -> bool:
43
+ """True if a live engine answers a ping on SOCKET_PATH.
44
+
45
+ Used by the daemon's health watchdog, and by the engine itself at
46
+ startup to refuse to double-run: a second engine binding the socket
47
+ path would silently starve the healthy instance (its listener stays
48
+ blocked on a socket inode that no longer receives anything).
49
+ """
50
+ import socket
51
+
52
+ if not SOCKET_PATH.exists():
53
+ return False
54
+ sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
55
+ try:
56
+ sock.settimeout(timeout)
57
+ sock.bind("") # Linux autobind: gives us an address for the reply
58
+ sock.sendto(b"ping", str(SOCKET_PATH))
59
+ data, _ = sock.recvfrom(64)
60
+ return data == b"pong"
61
+ except OSError:
62
+ return False
63
+ finally:
64
+ sock.close()
@@ -24,10 +24,18 @@ import gi
24
24
  gi.require_version("IBus", "1.0")
25
25
  from gi.repository import GLib, GObject, IBus
26
26
 
27
- from voiceio.ibus import READY_PATH, SOCKET_PATH, ping_engine
27
+ from voiceio.ibus import (
28
+ READY_PATH,
29
+ SOCKET_PATH,
30
+ acquire_singleton_lock,
31
+ )
28
32
  from voiceio.ibus.pending import PendingBuffer
29
33
 
30
34
  log = logging.getLogger(__name__)
35
+
36
+ # Singleton lock fd, held for the process lifetime (see main()). Module-level so
37
+ # it is never garbage-collected — closing the fd would release the lock.
38
+ _lock_fd: int | None = None
31
39
  ENGINE_NAME = "voiceio"
32
40
  COMPONENT_NAME = "org.voiceio.ibus"
33
41
 
@@ -230,13 +238,17 @@ def main() -> None:
230
238
  ],
231
239
  )
232
240
 
233
- # Refuse to double-run. ibus-daemon can exec-spawn its own copy of this
234
- # engine (component XML) when something activates the voiceio source
235
- # e.g. GNOME's per-window source memory. Binding SOCKET_PATH here would
236
- # steal it from the healthy daemon-spawned engine and silently starve it
237
- # (alive, but never sees another command or ping → zombie recovery).
238
- if ping_engine(timeout=0.5):
239
- log.info("Healthy VoiceIO engine already serving %s exiting duplicate", SOCKET_PATH)
241
+ # Refuse to double-run, race-free. ibus-daemon exec-spawns its own copy of
242
+ # this engine (component XML) when something activates the voiceio source
243
+ # (GNOME per-window source memory / Super+Space voiceio is first in
244
+ # mru-sources). Without an atomic gate, two engines both pass a ping check
245
+ # then both unlink+rebind SOCKET_PATH in _socket_listener; the loser is
246
+ # left on an orphaned inode — a "zombie" the watchdog kills and respawns.
247
+ # The lock fd is held for our whole lifetime (released on process death).
248
+ global _lock_fd
249
+ _lock_fd = acquire_singleton_lock()
250
+ if _lock_fd is None:
251
+ log.info("Another VoiceIO engine holds the lock — exiting duplicate")
240
252
  return
241
253
 
242
254
  IBus.init()
@@ -108,9 +108,12 @@ class AudioRecorder:
108
108
 
109
109
  # Auto-stop on sustained silence
110
110
  self._auto_stop_secs = cfg.auto_stop_silence_secs
111
+ self._no_speech_secs = cfg.auto_stop_no_speech_secs
111
112
  self._sustained_silence = 0.0
112
113
  self._heard_speech = False
113
- self._on_auto_stop: Callable[[], None] | None = None
114
+ # Called with a reason: "silence" (spoke then paused) or "no_speech"
115
+ # (nothing ever heard — a muted mic or a forgotten hotkey).
116
+ self._on_auto_stop: Callable[[str], None] | None = None
114
117
 
115
118
  # Heartbeat: updated by _callback, checked by health watchdog
116
119
  self._last_callback_time: float = 0.0
@@ -260,7 +263,7 @@ class AudioRecorder:
260
263
  """Set/clear the speech pause callback (used by streaming session)."""
261
264
  self._on_speech_pause = callback
262
265
 
263
- def set_on_auto_stop(self, callback: Callable[[], None] | None) -> None:
266
+ def set_on_auto_stop(self, callback: Callable[[str], None] | None) -> None:
264
267
  """Set/clear the auto-stop callback (fires after sustained silence)."""
265
268
  self._on_auto_stop = callback
266
269
 
@@ -340,4 +343,20 @@ class AudioRecorder:
340
343
  self._on_auto_stop = None
341
344
  self._sustained_silence = 0.0
342
345
  log.info("Auto-stopping after %.0fs of silence", self._auto_stop_secs)
343
- cb()
346
+ cb("silence")
347
+
348
+ # Safety net: nothing heard at all for a long time. Because speech
349
+ # resets _sustained_silence AND is gated out by not-_heard_speech, this
350
+ # measures continuous silence from the very start of the recording — a
351
+ # muted mic (all zeros) or a hotkey pressed by accident. Distinct reason
352
+ # so the app can surface it instead of failing silently.
353
+ elif (self._on_auto_stop is not None
354
+ and self._no_speech_secs > 0
355
+ and not self._heard_speech
356
+ and self._sustained_silence >= self._no_speech_secs):
357
+ cb = self._on_auto_stop
358
+ self._on_auto_stop = None
359
+ self._sustained_silence = 0.0
360
+ log.info("Auto-stopping: no speech heard in %.0fs (mic muted?)",
361
+ self._no_speech_secs)
362
+ cb("no_speech")
@@ -1,56 +0,0 @@
1
- """Tests for the shared engine ping used by the daemon health watchdog and
2
- the engine's own refuse-to-double-run guard (a duplicate engine binding the
3
- socket path silently starves the healthy instance)."""
4
- from __future__ import annotations
5
-
6
- import socket
7
- import sys
8
- import threading
9
-
10
- import pytest
11
-
12
- import voiceio.ibus as ibus_mod
13
-
14
- # IBus (and AF_UNIX DGRAM + Linux abstract autobind) is Linux-only.
15
- pytestmark = pytest.mark.skipif(sys.platform != "linux", reason="IBus is Linux-only")
16
-
17
-
18
- def test_no_socket_returns_false(tmp_path, monkeypatch):
19
- monkeypatch.setattr(ibus_mod, "SOCKET_PATH", tmp_path / "nope.sock")
20
- assert ibus_mod.ping_engine(timeout=0.2) is False
21
-
22
-
23
- def test_healthy_peer_answers_pong(tmp_path, monkeypatch):
24
- sock_path = tmp_path / "engine.sock"
25
- server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
26
- server.bind(str(sock_path))
27
- server.settimeout(2.0)
28
-
29
- def _serve():
30
- try:
31
- data, addr = server.recvfrom(64)
32
- if data == b"ping" and addr:
33
- server.sendto(b"pong", addr)
34
- except OSError:
35
- pass
36
-
37
- t = threading.Thread(target=_serve, daemon=True)
38
- t.start()
39
- monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
40
- try:
41
- assert ibus_mod.ping_engine(timeout=1.0) is True
42
- finally:
43
- t.join(timeout=2.0)
44
- server.close()
45
-
46
-
47
- def test_unresponsive_socket_returns_false(tmp_path, monkeypatch):
48
- """A bound-but-silent socket (starved listener) must read as dead."""
49
- sock_path = tmp_path / "engine.sock"
50
- server = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
51
- server.bind(str(sock_path))
52
- monkeypatch.setattr(ibus_mod, "SOCKET_PATH", sock_path)
53
- try:
54
- assert ibus_mod.ping_engine(timeout=0.2) is False
55
- finally:
56
- server.close()
@@ -1 +0,0 @@
1
- __version__ = "0.9.1"
@@ -1,33 +0,0 @@
1
- """IBus input method engine for VoiceIO."""
2
- from __future__ import annotations
3
-
4
- import os
5
- from pathlib import Path
6
-
7
- SOCKET_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.sock"
8
- READY_PATH = Path(os.environ.get("XDG_RUNTIME_DIR", "/tmp")) / "voiceio-ibus.ready"
9
-
10
-
11
- def ping_engine(timeout: float = 2.0) -> bool:
12
- """True if a live engine answers a ping on SOCKET_PATH.
13
-
14
- Used by the daemon's health watchdog, and by the engine itself at
15
- startup to refuse to double-run: a second engine binding the socket
16
- path would silently starve the healthy instance (its listener stays
17
- blocked on a socket inode that no longer receives anything).
18
- """
19
- import socket
20
-
21
- if not SOCKET_PATH.exists():
22
- return False
23
- sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM)
24
- try:
25
- sock.settimeout(timeout)
26
- sock.bind("") # Linux autobind: gives us an address for the reply
27
- sock.sendto(b"ping", str(SOCKET_PATH))
28
- data, _ = sock.recvfrom(64)
29
- return data == b"pong"
30
- except OSError:
31
- return False
32
- finally:
33
- sock.close()
File without changes
File without changes
File without changes