python-voiceio 0.9.9__tar.gz → 0.9.10__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.9/python_voiceio.egg-info → python_voiceio-0.9.10}/PKG-INFO +1 -1
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/pyproject.toml +1 -1
- {python_voiceio-0.9.9 → python_voiceio-0.9.10/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_robustness.py +37 -0
- python_voiceio-0.9.10/voiceio/__init__.py +1 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/app.py +58 -4
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/feedback.py +15 -10
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/recorder.py +11 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/__init__.py +29 -0
- python_voiceio-0.9.9/voiceio/__init__.py +0 -1
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/LICENSE +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/README.md +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/setup.cfg +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_adjudicate.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_app_wiring.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_audio_quality.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_audit.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_autocorrect.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_autocorrect_state.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_backend_probes.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_cli.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_commands.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_config.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_correct_batch.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_corrections.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_evaluate.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_fallback.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_health.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_hints.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_history.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_llm.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_llm_api.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_numbers.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_platform.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_postcorrect.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_postprocess.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_prebuffer.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_prompt.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_retention.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_security_hardening.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_snapshots.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_streaming.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_tokens.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_transcriber.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_tts.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_vad.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_vocabulary.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_wizard.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/tests/test_wordfreq.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/__main__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/audit.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/autocorrect.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/autocorrect_state.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/backends.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/cli.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/commands.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/config.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/consent.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/corrections.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/demo.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/evaluate.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/health.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hints.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/history.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/llm.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/llm_api.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/models/__init__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/numbers.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/pidlock.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/platform.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/postcorrect.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/postprocess.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/prompt.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/retention.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/service.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/snapshots.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/streaming.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tokens.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/transcriber.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/base.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/chain.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/tts/player.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/base.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/chain.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/vad.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/vocabulary.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/wizard.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/wordfreq.py +0 -0
- {python_voiceio-0.9.9 → python_voiceio-0.9.10}/voiceio/worker.py +0 -0
|
@@ -163,6 +163,43 @@ class TestAutoStopNotification:
|
|
|
163
163
|
assert not notify.called
|
|
164
164
|
|
|
165
165
|
|
|
166
|
+
class TestNothingCapturedWarning:
|
|
167
|
+
"""A muted mic must be reported when the recording ENDS — the moment the
|
|
168
|
+
user looks for text and finds none — however it was stopped."""
|
|
169
|
+
|
|
170
|
+
def test_warns_when_no_signal_and_no_text(self):
|
|
171
|
+
vio, _, _ = _make_vio()
|
|
172
|
+
vio.recorder._heard_signal = False
|
|
173
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
174
|
+
vio._warn_if_nothing_captured("", vio._generation)
|
|
175
|
+
assert notify.called
|
|
176
|
+
assert "mute" in " ".join(notify.call_args[0]).lower()
|
|
177
|
+
assert notify.call_args.kwargs.get("urgent") is True # must persist
|
|
178
|
+
|
|
179
|
+
def test_quiet_when_text_was_produced(self):
|
|
180
|
+
vio, _, _ = _make_vio()
|
|
181
|
+
vio.recorder._heard_signal = False
|
|
182
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
183
|
+
vio._warn_if_nothing_captured("[voice] hello there", vio._generation)
|
|
184
|
+
assert not notify.called
|
|
185
|
+
|
|
186
|
+
def test_quiet_when_mic_had_signal(self):
|
|
187
|
+
"""Mic works, user just said nothing — not a fault, stay quiet."""
|
|
188
|
+
vio, _, _ = _make_vio()
|
|
189
|
+
vio.recorder._heard_signal = True
|
|
190
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
191
|
+
vio._warn_if_nothing_captured("", vio._generation)
|
|
192
|
+
assert not notify.called
|
|
193
|
+
|
|
194
|
+
def test_superseded_generation_does_not_warn(self):
|
|
195
|
+
"""A newer recording owns the recorder — don't judge this take by it."""
|
|
196
|
+
vio, _, _ = _make_vio()
|
|
197
|
+
vio.recorder._heard_signal = False
|
|
198
|
+
with patch("voiceio.feedback.notify") as notify:
|
|
199
|
+
vio._warn_if_nothing_captured("", vio._generation - 1)
|
|
200
|
+
assert not notify.called
|
|
201
|
+
|
|
202
|
+
|
|
166
203
|
# ===========================================================================
|
|
167
204
|
# 1. stream_health() tests
|
|
168
205
|
# ===========================================================================
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "0.9.10"
|
|
@@ -253,6 +253,9 @@ class VoiceIO:
|
|
|
253
253
|
# system is not a zombie) and a guard against concurrent spawns
|
|
254
254
|
# (health loop vs record-start).
|
|
255
255
|
self._engine_ping_fails = 0
|
|
256
|
+
# Engine stderr goes to a file (never a pipe — see _spawn_ibus_engine).
|
|
257
|
+
self._engine_stderr_file = None
|
|
258
|
+
self._engine_stderr_path = None
|
|
256
259
|
self._engine_spawn_lock = threading.Lock()
|
|
257
260
|
self._shutdown = threading.Event()
|
|
258
261
|
# IBus input-source ownership. We claim the GNOME input source ONLY
|
|
@@ -308,6 +311,29 @@ class VoiceIO:
|
|
|
308
311
|
)
|
|
309
312
|
threading.Thread(target=self._request_stop, daemon=True).start()
|
|
310
313
|
|
|
314
|
+
def _warn_if_nothing_captured(self, final_text: str, gen: int) -> None:
|
|
315
|
+
"""Warn when a finished recording captured no audio at all.
|
|
316
|
+
|
|
317
|
+
This is the moment the user actually notices — they look for their text
|
|
318
|
+
and nothing appeared. The pre-flight warning is a 3s toast they talk
|
|
319
|
+
straight through, and the 20s no-speech auto-stop never fires on a
|
|
320
|
+
shorter take stopped by hand, which is exactly how a muted mic slips
|
|
321
|
+
by silently. Generation-guarded: a newer recording owns the recorder,
|
|
322
|
+
so never read its state to judge this one.
|
|
323
|
+
"""
|
|
324
|
+
if self._generation != gen:
|
|
325
|
+
return
|
|
326
|
+
if final_text.strip() or self.recorder.heard_signal:
|
|
327
|
+
return
|
|
328
|
+
log.warning("Recording captured no audio at all — mic muted?")
|
|
329
|
+
from voiceio import feedback
|
|
330
|
+
feedback.notify(
|
|
331
|
+
"VoiceIO: microphone is muted — nothing recorded",
|
|
332
|
+
"No audio reached voiceio for that entire recording. Check your "
|
|
333
|
+
"mic's mute button and which input device is selected.",
|
|
334
|
+
urgent=True,
|
|
335
|
+
)
|
|
336
|
+
|
|
311
337
|
def _request_stop(self) -> None:
|
|
312
338
|
"""Stop recording if active. Unlike on_hotkey, never starts."""
|
|
313
339
|
with self._hotkey_lock:
|
|
@@ -418,6 +444,7 @@ class VoiceIO:
|
|
|
418
444
|
feedback.notify(
|
|
419
445
|
"VoiceIO: microphone unavailable",
|
|
420
446
|
"Could not reopen the audio stream — check your input device.",
|
|
447
|
+
urgent=True,
|
|
421
448
|
)
|
|
422
449
|
return
|
|
423
450
|
if not self.recorder.has_signal():
|
|
@@ -426,6 +453,7 @@ class VoiceIO:
|
|
|
426
453
|
feedback.notify(
|
|
427
454
|
"VoiceIO: microphone appears silent",
|
|
428
455
|
"Check that your mic is unmuted and the right input device is selected.",
|
|
456
|
+
urgent=True,
|
|
429
457
|
)
|
|
430
458
|
elif not self.recorder.has_signal():
|
|
431
459
|
log.warning("Mic appears silent or muted (pre-buffer is all zeros)")
|
|
@@ -639,6 +667,7 @@ class VoiceIO:
|
|
|
639
667
|
"passes": list(session.trace),
|
|
640
668
|
})
|
|
641
669
|
log.info("Streaming done (%.1fs): '%s'", elapsed, final_text)
|
|
670
|
+
self._warn_if_nothing_captured(final_text, gen)
|
|
642
671
|
# Release the IBus input source now that the final commit is done.
|
|
643
672
|
# Generation-checked inside: if a newer recording started, it already
|
|
644
673
|
# re-claimed the source and we must not restore it out from under it.
|
|
@@ -989,6 +1018,18 @@ class VoiceIO:
|
|
|
989
1018
|
except Exception:
|
|
990
1019
|
log.exception("IBus engine respawn at record start failed")
|
|
991
1020
|
|
|
1021
|
+
def _engine_stderr_tail(self, limit: int = 500) -> str:
|
|
1022
|
+
"""Last bytes of the engine's stderr file, for crash reporting."""
|
|
1023
|
+
path = getattr(self, "_engine_stderr_path", None)
|
|
1024
|
+
if path is None:
|
|
1025
|
+
return ""
|
|
1026
|
+
try:
|
|
1027
|
+
if self._engine_stderr_file is not None:
|
|
1028
|
+
self._engine_stderr_file.flush()
|
|
1029
|
+
return path.read_text(errors="replace").strip()[-limit:]
|
|
1030
|
+
except OSError:
|
|
1031
|
+
return ""
|
|
1032
|
+
|
|
992
1033
|
def _ensure_ibus_engine(self, activate: bool = True) -> None:
|
|
993
1034
|
"""Start the VoiceIO IBus engine, optionally pre-creating its instance.
|
|
994
1035
|
|
|
@@ -1009,6 +1050,7 @@ class VoiceIO:
|
|
|
1009
1050
|
self._engine_spawn_lock.release()
|
|
1010
1051
|
|
|
1011
1052
|
def _spawn_ibus_engine(self, activate: bool = True) -> None:
|
|
1053
|
+
from voiceio.config import LOG_DIR
|
|
1012
1054
|
from voiceio.ibus import READY_PATH, SOCKET_PATH
|
|
1013
1055
|
from voiceio.typers.ibus import LAUNCHER_PATH, _ibus_env
|
|
1014
1056
|
|
|
@@ -1032,12 +1074,24 @@ class VoiceIO:
|
|
|
1032
1074
|
self._kill_stale_engine(SOCKET_PATH)
|
|
1033
1075
|
READY_PATH.unlink(missing_ok=True)
|
|
1034
1076
|
|
|
1035
|
-
# Spawn the engine process directly
|
|
1077
|
+
# Spawn the engine process directly. stderr goes to a FILE, never a
|
|
1078
|
+
# pipe: the engine logs at DEBUG through a StreamHandler, and a piped,
|
|
1079
|
+
# undrained stderr fills its 64KB buffer and blocks the engine
|
|
1080
|
+
# mid-write — alive but frozen, so it stops answering pings and the
|
|
1081
|
+
# watchdog reaps it (the recurring "not responding to ping" churn).
|
|
1082
|
+
# The worker already does it this way.
|
|
1036
1083
|
log.info("Starting VoiceIO IBus engine...")
|
|
1084
|
+
stderr_target = subprocess.DEVNULL
|
|
1085
|
+
try:
|
|
1086
|
+
self._engine_stderr_path = LOG_DIR / "ibus-engine-stderr.log"
|
|
1087
|
+
self._engine_stderr_file = open(self._engine_stderr_path, "w")
|
|
1088
|
+
stderr_target = self._engine_stderr_file
|
|
1089
|
+
except OSError:
|
|
1090
|
+
self._engine_stderr_path = None
|
|
1037
1091
|
try:
|
|
1038
1092
|
self._engine_proc = subprocess.Popen(
|
|
1039
1093
|
[str(LAUNCHER_PATH)],
|
|
1040
|
-
stdout=subprocess.DEVNULL, stderr=
|
|
1094
|
+
stdout=subprocess.DEVNULL, stderr=stderr_target,
|
|
1041
1095
|
env=ibus_env,
|
|
1042
1096
|
)
|
|
1043
1097
|
except OSError as e:
|
|
@@ -1052,8 +1106,8 @@ class VoiceIO:
|
|
|
1052
1106
|
time.sleep(0.1)
|
|
1053
1107
|
else:
|
|
1054
1108
|
if self._engine_proc.poll() is not None:
|
|
1055
|
-
|
|
1056
|
-
|
|
1109
|
+
log.error("IBus engine crashed (rc=%d): %s",
|
|
1110
|
+
self._engine_proc.returncode, self._engine_stderr_tail())
|
|
1057
1111
|
else:
|
|
1058
1112
|
log.warning("IBus engine started but socket not found, commands may fail")
|
|
1059
1113
|
return
|
|
@@ -106,10 +106,14 @@ def _play_sync(name: str) -> None:
|
|
|
106
106
|
log.debug("Playback failed for %s", name, exc_info=True)
|
|
107
107
|
|
|
108
108
|
|
|
109
|
-
def notify(title: str, body: str) -> None:
|
|
110
|
-
"""Send a desktop notification without blocking the caller.
|
|
109
|
+
def notify(title: str, body: str, *, urgent: bool = False) -> None:
|
|
110
|
+
"""Send a desktop notification without blocking the caller.
|
|
111
|
+
|
|
112
|
+
``urgent=True`` marks it critical so the desktop keeps it on screen until
|
|
113
|
+
dismissed — for actionable failures (a muted mic) that a 3s toast loses.
|
|
114
|
+
"""
|
|
111
115
|
threading.Thread(
|
|
112
|
-
target=_send_notification, args=(title, body), daemon=True,
|
|
116
|
+
target=_send_notification, args=(title, body, urgent), daemon=True,
|
|
113
117
|
).start()
|
|
114
118
|
|
|
115
119
|
|
|
@@ -118,7 +122,7 @@ def notify_clipboard(text: str) -> None:
|
|
|
118
122
|
notify("VoiceIO: copied to clipboard", preview)
|
|
119
123
|
|
|
120
124
|
|
|
121
|
-
def _send_notification(title: str, body: str) -> None:
|
|
125
|
+
def _send_notification(title: str, body: str, urgent: bool = False) -> None:
|
|
122
126
|
import sys
|
|
123
127
|
|
|
124
128
|
if sys.platform == "win32":
|
|
@@ -126,17 +130,18 @@ def _send_notification(title: str, body: str) -> None:
|
|
|
126
130
|
elif sys.platform == "darwin":
|
|
127
131
|
_send_notification_macos(title, body)
|
|
128
132
|
else:
|
|
129
|
-
_send_notification_linux(title, body)
|
|
133
|
+
_send_notification_linux(title, body, urgent)
|
|
130
134
|
|
|
131
135
|
|
|
132
|
-
def _send_notification_linux(title: str, body: str) -> None:
|
|
136
|
+
def _send_notification_linux(title: str, body: str, urgent: bool = False) -> None:
|
|
133
137
|
if not _which("notify-send"):
|
|
134
138
|
return
|
|
139
|
+
# Critical urgency makes the desktop hold the notification until dismissed;
|
|
140
|
+
# a normal 3s toast is missed while the user is mid-dictation.
|
|
141
|
+
args = ["notify-send", "--app-name=VoiceIO"]
|
|
142
|
+
args += ["-u", "critical"] if urgent else ["-t", "3000"]
|
|
135
143
|
try:
|
|
136
|
-
subprocess.run(
|
|
137
|
-
["notify-send", "--app-name=VoiceIO", "-t", "3000", title, body],
|
|
138
|
-
capture_output=True, timeout=3,
|
|
139
|
-
)
|
|
144
|
+
subprocess.run([*args, title, body], capture_output=True, timeout=3)
|
|
140
145
|
except (subprocess.TimeoutExpired, OSError):
|
|
141
146
|
pass
|
|
142
147
|
|
|
@@ -185,6 +185,17 @@ class AudioRecorder:
|
|
|
185
185
|
return False # ring not yet full — can't judge
|
|
186
186
|
return not self.has_signal()
|
|
187
187
|
|
|
188
|
+
@property
|
|
189
|
+
def heard_signal(self) -> bool:
|
|
190
|
+
"""True if ANY real audio arrived during the recording just captured.
|
|
191
|
+
|
|
192
|
+
Latches on the first non-silent chunk and is reset by start(), so after
|
|
193
|
+
stop() it answers "did the mic actually deliver anything?" — False means
|
|
194
|
+
a muted or dead mic for the whole take, whether it ended by hotkey or
|
|
195
|
+
auto-stop.
|
|
196
|
+
"""
|
|
197
|
+
return self._heard_signal
|
|
198
|
+
|
|
188
199
|
def has_signal(self) -> bool:
|
|
189
200
|
"""Check if the pre-buffer ring contains non-silence audio.
|
|
190
201
|
|
|
@@ -131,6 +131,27 @@ _MENU_COMMANDS: dict[str, list[str]] = {
|
|
|
131
131
|
}
|
|
132
132
|
|
|
133
133
|
|
|
134
|
+
def _read_stderr(proc: subprocess.Popen) -> None:
|
|
135
|
+
"""Drain the indicator's stderr so it can never block on a full pipe.
|
|
136
|
+
|
|
137
|
+
GTK/AppIndicator emit warnings continuously. With stderr piped and nobody
|
|
138
|
+
reading it, the 64KB pipe buffer fills and the tray process blocks forever
|
|
139
|
+
inside write() — the process stays "alive" (so is_alive() sees nothing
|
|
140
|
+
wrong) while its GLib main loop is frozen, which silently kills the icon
|
|
141
|
+
animation, the menu, AND the registration watchdog that would otherwise
|
|
142
|
+
re-register a dropped icon. Observed live: wchan=anon_pipe_write, icon gone
|
|
143
|
+
for ~2 days. Logged at debug so the diagnostics survive without noise.
|
|
144
|
+
"""
|
|
145
|
+
try:
|
|
146
|
+
while proc.poll() is None and proc.stderr:
|
|
147
|
+
line = proc.stderr.readline()
|
|
148
|
+
if not line:
|
|
149
|
+
break
|
|
150
|
+
log.debug("tray: %s", line.decode(errors="replace").rstrip())
|
|
151
|
+
except (OSError, ValueError):
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
|
|
134
155
|
def _read_stdout(proc: subprocess.Popen, toggle_cb: Callable[[], None]) -> None:
|
|
135
156
|
"""Read toggle/menu commands from indicator subprocess stdout."""
|
|
136
157
|
from voiceio.platform import open_in_terminal
|
|
@@ -194,6 +215,14 @@ def start(
|
|
|
194
215
|
)
|
|
195
216
|
_backend = "indicator"
|
|
196
217
|
|
|
218
|
+
# Always drain stderr — unconditionally, and regardless of whether
|
|
219
|
+
# a toggle callback wants stdout. An undrained pipe blocks the tray
|
|
220
|
+
# process mid-write and freezes its GLib loop (see _read_stderr).
|
|
221
|
+
threading.Thread(
|
|
222
|
+
target=_read_stderr, args=(_proc,), daemon=True,
|
|
223
|
+
name="tray-stderr",
|
|
224
|
+
).start()
|
|
225
|
+
|
|
197
226
|
if toggle_callback is not None:
|
|
198
227
|
_stdout_thread = threading.Thread(
|
|
199
228
|
target=_read_stdout,
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
__version__ = "0.9.9"
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|