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.
- {python_voiceio-0.9.1/python_voiceio.egg-info → python_voiceio-0.9.3}/PKG-INFO +1 -1
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/pyproject.toml +1 -1
- {python_voiceio-0.9.1 → python_voiceio-0.9.3/python_voiceio.egg-info}/PKG-INFO +1 -1
- python_voiceio-0.9.3/tests/test_ibus_ping.py +113 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_robustness.py +73 -0
- python_voiceio-0.9.3/voiceio/__init__.py +1 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/app.py +22 -2
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/config.py +5 -0
- python_voiceio-0.9.3/voiceio/ibus/__init__.py +64 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/ibus/engine.py +20 -8
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/recorder.py +22 -3
- python_voiceio-0.9.1/tests/test_ibus_ping.py +0 -56
- python_voiceio-0.9.1/voiceio/__init__.py +0 -1
- python_voiceio-0.9.1/voiceio/ibus/__init__.py +0 -33
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/LICENSE +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/README.md +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/setup.cfg +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_audit.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_cli.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_commands.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_config.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_corrections.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_evaluate.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_fallback.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_health.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_hints.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_history.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_llm.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_numbers.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_platform.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_prompt.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_retention.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_streaming.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_tokens.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_tts.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_vad.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_wizard.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/__main__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/audit.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/backends.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/cli.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/commands.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/consent.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/corrections.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/demo.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/evaluate.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/feedback.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/health.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hints.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/history.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/llm.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/numbers.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/platform.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/prompt.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/retention.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/service.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/streaming.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tokens.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/vad.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/wizard.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.9.1 → python_voiceio-0.9.3}/voiceio/worker.py +0 -0
|
@@ -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
|
|
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
|
|
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
|
|
234
|
-
# engine (component XML) when something activates the voiceio source
|
|
235
|
-
#
|
|
236
|
-
#
|
|
237
|
-
#
|
|
238
|
-
|
|
239
|
-
|
|
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
|
-
|
|
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
|
|
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
|