python-voiceio 1.8.3__tar.gz → 1.9.0__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-1.8.3/python_voiceio.egg-info → python_voiceio-1.9.0}/PKG-INFO +1 -1
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/pyproject.toml +1 -1
- {python_voiceio-1.8.3 → python_voiceio-1.9.0/python_voiceio.egg-info}/PKG-INFO +1 -1
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/SOURCES.txt +2 -0
- python_voiceio-1.9.0/tests/test_context_terms.py +253 -0
- python_voiceio-1.9.0/voiceio/__init__.py +1 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/app.py +31 -5
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/config.py +4 -0
- python_voiceio-1.9.0/voiceio/context_terms.py +253 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/bias.py +52 -10
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/engine.py +10 -4
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/health.py +6 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/streaming.py +8 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tokens.py +17 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/vocabulary.py +6 -15
- python_voiceio-1.8.3/voiceio/__init__.py +0 -1
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/LICENSE +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/README.md +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/setup.cfg +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_app_wiring.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_audio_source.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_cancel.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_cli.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_clipboard_typer.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_commands.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_config.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_corrections.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_decoders.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_engine.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_evdev_uaccess.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_fallback.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_fcitx_bridge.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_feedback_stream.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_focus.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_health.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_hints.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_history.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_hotkey_modes.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_engine_limits.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_engine_listener.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_pids.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_session.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_textlimit.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_idle_audio.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_clean.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_import.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_language.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_metrics.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_pipeline.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_quiet.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_review.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_learn_targets.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_llm.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_modifier_guard.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_numbers.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_paste_last.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_pipeline.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_platform.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_postcorrect.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_prompt.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_quiet_audio.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_retention.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_robustness.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_server.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_service.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_setup.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_streaming.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_suggest.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_tokens.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_transcriber.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_tts.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_typer_manager.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_vad.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/tests/test_worker_decode.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/__main__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/audio_source.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/backends.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/configcmd.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/corrections.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/doctor.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/history.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/learn.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/models.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/servicecmd.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/uninstall.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/cli/vocab.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/commands.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/consent.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/base.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/catalog.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/lanes.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/sherpa.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/decoders/whispercpp.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/core/pipeline.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/corrections.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/data/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/demo.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/display.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/feedback.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/focus.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hints.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/history.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/install.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/session.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ibus/textlimit.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/idle_audio.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/augment.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/clean.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/evaluate.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/importer.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/metrics.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/quiet.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/review.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/store.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/teacher.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/learn/train.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/llm.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/numbers.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/pidlock.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/platform.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/postcorrect.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/power.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/privacy.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/prompt.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/quiet_audio.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/recorder.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/retention.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/__main__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/app.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/config.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/server/runtime.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/service.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/configfile.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/extras.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/hotkey.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/noninteractive.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/speech.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/steps.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/system.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/setup/ui.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/suggest.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/collecting.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/fcitx_bridge.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/manager.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/modifier_guard.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/paste.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/ui.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/vad.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/watchdog.py +0 -0
- {python_voiceio-1.8.3 → python_voiceio-1.9.0}/voiceio/wordfreq.py +0 -0
|
@@ -18,6 +18,7 @@ tests/test_clipboard_typer.py
|
|
|
18
18
|
tests/test_commands.py
|
|
19
19
|
tests/test_concurrency_lockdown.py
|
|
20
20
|
tests/test_config.py
|
|
21
|
+
tests/test_context_terms.py
|
|
21
22
|
tests/test_corrections.py
|
|
22
23
|
tests/test_decoders.py
|
|
23
24
|
tests/test_engine.py
|
|
@@ -85,6 +86,7 @@ voiceio/clipboard_read.py
|
|
|
85
86
|
voiceio/commands.py
|
|
86
87
|
voiceio/config.py
|
|
87
88
|
voiceio/consent.py
|
|
89
|
+
voiceio/context_terms.py
|
|
88
90
|
voiceio/corrections.py
|
|
89
91
|
voiceio/demo.py
|
|
90
92
|
voiceio/display.py
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""On-screen vocabulary: term extraction, budget share, capture deadline, and
|
|
2
|
+
that capture never sits on the hotkey path."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import threading
|
|
6
|
+
import time
|
|
7
|
+
from unittest.mock import patch
|
|
8
|
+
|
|
9
|
+
import pytest
|
|
10
|
+
|
|
11
|
+
from voiceio import context_terms
|
|
12
|
+
from voiceio.context_terms import ScreenContext, capture, extract_terms
|
|
13
|
+
from voiceio.core.bias import SCREEN_TOKEN_SHARE, BiasProvider
|
|
14
|
+
from voiceio.core.decoders.base import BiasMode, Capabilities
|
|
15
|
+
from voiceio.tokens import count_tokens
|
|
16
|
+
|
|
17
|
+
HOTWORDS = Capabilities(bias=BiasMode.HOTWORDS | BiasMode.PROMPT, max_bias_tokens=120)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
# ── extraction ────────────────────────────────────────────────────────────
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
class TestExtract:
|
|
24
|
+
def test_rare_names_camelcase_acronyms(self):
|
|
25
|
+
terms = extract_terms(["Kalshi pricing - Discord",
|
|
26
|
+
"The HyperNEAT paper uses OpenRouter and the OKR doc."])
|
|
27
|
+
assert {"Kalshi", "HyperNEAT", "OpenRouter", "OKR"} <= set(terms)
|
|
28
|
+
|
|
29
|
+
def test_common_words_and_app_names_are_not_terms(self):
|
|
30
|
+
terms = extract_terms(["Inbox - Google Chrome",
|
|
31
|
+
"Please check the API before the meeting with Hugo."])
|
|
32
|
+
assert terms == []
|
|
33
|
+
|
|
34
|
+
def test_multiword_name_but_not_a_greeting(self):
|
|
35
|
+
terms = extract_terms(["", "Hey Kosh, ask Tarrant Fnordwick about it."])
|
|
36
|
+
assert "Tarrant Fnordwick" in terms
|
|
37
|
+
assert "Kosh" in terms
|
|
38
|
+
assert not any(t.startswith("Hey") for t in terms)
|
|
39
|
+
# A name and its own parts would spend tokens twice on one word.
|
|
40
|
+
assert "Tarrant" not in terms and "Fnordwick" not in terms
|
|
41
|
+
|
|
42
|
+
def test_identifiers_split_numbers_dropped(self):
|
|
43
|
+
terms = extract_terms(["", "see github.com/Hugo0/voiceio and v2 of x86_64 build 2026"])
|
|
44
|
+
assert "voiceio" in terms
|
|
45
|
+
assert not any(any(c.isdigit() for c in t) for t in terms)
|
|
46
|
+
|
|
47
|
+
def test_exclude_is_case_insensitive_and_dedupes_spellings(self):
|
|
48
|
+
terms = extract_terms(["", "Kalshi kalshi KALSHI Zorblax"], exclude=["zorblax"])
|
|
49
|
+
assert [t.lower() for t in terms] == ["kalshi"]
|
|
50
|
+
|
|
51
|
+
def test_vocabulary_long_tail_ranks_first(self):
|
|
52
|
+
terms = extract_terms(["", "Zorblax met Quuxley at Wibblecorp."],
|
|
53
|
+
prefer=["Wibblecorp"])
|
|
54
|
+
assert terms[0] == "Wibblecorp"
|
|
55
|
+
|
|
56
|
+
def test_title_beats_selection_on_ties(self):
|
|
57
|
+
terms = extract_terms(["Quuxley", "Zorblax"])
|
|
58
|
+
assert terms == ["Quuxley", "Zorblax"]
|
|
59
|
+
|
|
60
|
+
def test_repeats_outrank_one_offs(self):
|
|
61
|
+
terms = extract_terms(["", "Zorblax once. Quuxley, Quuxley, Quuxley."])
|
|
62
|
+
assert terms[0] == "Quuxley"
|
|
63
|
+
|
|
64
|
+
def test_capped(self):
|
|
65
|
+
text = " ".join(f"Zorb{chr(97 + i)}lax" for i in range(20))
|
|
66
|
+
assert len(extract_terms(["", text], max_terms=5)) == 5
|
|
67
|
+
|
|
68
|
+
def test_huge_selection_is_cheap(self):
|
|
69
|
+
text = "the quick brown fox Zorblax jumps " * 20000
|
|
70
|
+
t0 = time.monotonic()
|
|
71
|
+
assert extract_terms(["", text]) == ["Zorblax"]
|
|
72
|
+
assert time.monotonic() - t0 < 1.0
|
|
73
|
+
|
|
74
|
+
def test_empty(self):
|
|
75
|
+
assert extract_terms(["", ""]) == []
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
# ── budget share in the hotwords ──────────────────────────────────────────
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _provider(terms: list[str]) -> BiasProvider:
|
|
82
|
+
from voiceio import config
|
|
83
|
+
from voiceio.vocabulary import VocabularyLoader
|
|
84
|
+
config.CONFIG_DIR.mkdir(parents=True, exist_ok=True)
|
|
85
|
+
(config.CONFIG_DIR / "vocabulary.txt").write_text("\n".join(terms) + "\n")
|
|
86
|
+
return BiasProvider(VocabularyLoader(config.ModelConfig()))
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _vocab(n: int) -> list[str]:
|
|
90
|
+
# Distinct pronounceable made-up names, in file (= rank) order.
|
|
91
|
+
syll = ["ka", "zor", "bli", "mun", "tep", "vas", "quo", "rin", "lud", "fey"]
|
|
92
|
+
return [(syll[i % 10] + syll[(i // 10) % 10] + syll[(i * 7) % 10]).capitalize() + "x"
|
|
93
|
+
for i in range(n)]
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
class TestBudget:
|
|
97
|
+
def test_no_screen_terms_is_unchanged(self):
|
|
98
|
+
bp = _provider(_vocab(60))
|
|
99
|
+
assert bp.bias_for(HOTWORDS).hotwords == bp.bias_for(HOTWORDS, []).hotwords
|
|
100
|
+
|
|
101
|
+
def test_screen_terms_displace_only_the_vocabulary_tail(self):
|
|
102
|
+
vocab = _vocab(60)
|
|
103
|
+
bp = _provider(vocab)
|
|
104
|
+
base = bp.bias_for(HOTWORDS).hotwords.split(", ")
|
|
105
|
+
merged = bp.bias_for(HOTWORDS, ["Quuxley", "Wibblecorp"]).hotwords.split(", ")
|
|
106
|
+
assert "Quuxley" in merged and "Wibblecorp" in merged
|
|
107
|
+
kept = [t for t in merged if t in vocab]
|
|
108
|
+
assert kept == base[:len(kept)], "the best-ranked terms survive, in order"
|
|
109
|
+
assert len(kept) < len(base)
|
|
110
|
+
assert count_tokens(", ".join(merged), "small") <= 120 + 2 # join slack
|
|
111
|
+
|
|
112
|
+
def test_screen_share_is_capped(self):
|
|
113
|
+
bp = _provider(_vocab(60))
|
|
114
|
+
many = [f"Zorb{chr(97 + i)}laxian" for i in range(20)]
|
|
115
|
+
merged = bp.bias_for(HOTWORDS, many).hotwords.split(", ")
|
|
116
|
+
screen = [t for t in merged if t in many]
|
|
117
|
+
assert 0 < len(screen) < len(many)
|
|
118
|
+
assert count_tokens(", ".join(screen), "small") <= SCREEN_TOKEN_SHARE
|
|
119
|
+
# The vocabulary keeps the rest of the budget.
|
|
120
|
+
assert len(merged) - len(screen) >= 10
|
|
121
|
+
|
|
122
|
+
def test_screen_terms_skip_what_hotwords_carry(self):
|
|
123
|
+
bp = _provider(["Kalshi", "HyperNEAT"])
|
|
124
|
+
assert bp.screen_terms(["Kalshi - Discord", "Quuxley and HyperNEAT"], HOTWORDS) == ["Quuxley"]
|
|
125
|
+
|
|
126
|
+
def test_unbounded_appends(self):
|
|
127
|
+
bp = _provider(_vocab(60))
|
|
128
|
+
caps = Capabilities(bias=BiasMode.UNBOUNDED)
|
|
129
|
+
merged = bp.bias_for(caps, ["Quuxley"]).hotwords.split(", ")
|
|
130
|
+
assert merged[-1] == "Quuxley" and len(merged) == 61
|
|
131
|
+
|
|
132
|
+
def test_decoder_without_hotwords_gets_nothing(self):
|
|
133
|
+
bp = _provider(["Kalshi"])
|
|
134
|
+
assert bp.screen_terms(["Quuxley"], Capabilities(bias=BiasMode.PROMPT)) == []
|
|
135
|
+
|
|
136
|
+
def test_extraction_failure_degrades_to_nothing(self):
|
|
137
|
+
bp = _provider(["Kalshi"])
|
|
138
|
+
with patch("voiceio.context_terms.extract_terms", side_effect=RuntimeError):
|
|
139
|
+
assert bp.screen_terms(["Quuxley"], HOTWORDS) == []
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
# ── capture: deadline and degrade ─────────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class TestCapture:
|
|
146
|
+
def test_slow_source_is_dropped_at_the_deadline(self):
|
|
147
|
+
gate = threading.Event()
|
|
148
|
+
|
|
149
|
+
def slow(_t):
|
|
150
|
+
gate.wait(5)
|
|
151
|
+
return "Zorblax"
|
|
152
|
+
|
|
153
|
+
t0 = time.monotonic()
|
|
154
|
+
ctx = capture(0.1, readers={"title": lambda _t: "Kalshi", "selection": slow})
|
|
155
|
+
elapsed = time.monotonic() - t0
|
|
156
|
+
gate.set()
|
|
157
|
+
assert elapsed < 0.5
|
|
158
|
+
assert ctx.title == "Kalshi" and ctx.selection == ""
|
|
159
|
+
|
|
160
|
+
def test_failing_source_degrades_silently(self):
|
|
161
|
+
def boom(_t):
|
|
162
|
+
raise OSError("no display")
|
|
163
|
+
ctx = capture(0.1, readers={"title": boom, "selection": lambda _t: "x"})
|
|
164
|
+
assert ctx == ScreenContext(title="", selection="x", secs=ctx.secs)
|
|
165
|
+
|
|
166
|
+
def test_no_tools_no_selection(self, monkeypatch):
|
|
167
|
+
monkeypatch.setattr(context_terms.shutil, "which", lambda _n: None)
|
|
168
|
+
assert context_terms._read_primary(0.1) == ""
|
|
169
|
+
|
|
170
|
+
def test_gnome_wayland_never_uses_wl_paste(self, monkeypatch):
|
|
171
|
+
"""wl-paste can take focus on GNOME and drop the IBus preedit."""
|
|
172
|
+
from types import SimpleNamespace
|
|
173
|
+
monkeypatch.delenv("DISPLAY", raising=False)
|
|
174
|
+
monkeypatch.setattr("voiceio.platform.detect",
|
|
175
|
+
lambda: SimpleNamespace(is_wayland=True, desktop="gnome"))
|
|
176
|
+
monkeypatch.setattr(context_terms.shutil, "which", lambda n: f"/usr/bin/{n}")
|
|
177
|
+
with patch.object(context_terms.subprocess, "run") as run:
|
|
178
|
+
assert context_terms._read_primary(0.1) == ""
|
|
179
|
+
run.assert_not_called()
|
|
180
|
+
|
|
181
|
+
def test_selection_read_has_the_deadline_as_timeout(self, monkeypatch):
|
|
182
|
+
monkeypatch.setenv("DISPLAY", ":0")
|
|
183
|
+
monkeypatch.setattr(context_terms.shutil, "which", lambda n: f"/usr/bin/{n}")
|
|
184
|
+
with patch.object(context_terms.subprocess, "run") as run:
|
|
185
|
+
run.return_value.returncode = 0
|
|
186
|
+
run.return_value.stdout = "Zorblax".encode()
|
|
187
|
+
assert context_terms._read_primary(0.15) == "Zorblax"
|
|
188
|
+
assert run.call_args.kwargs["timeout"] == 0.15
|
|
189
|
+
assert run.call_args.args[0][:2] == ["xclip", "-o"]
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ── the daemon: never on the hotkey path ──────────────────────────────────
|
|
193
|
+
|
|
194
|
+
|
|
195
|
+
@pytest.fixture
|
|
196
|
+
def vio():
|
|
197
|
+
from test_app_wiring import _make_vio
|
|
198
|
+
from fakes import FakeDecoder
|
|
199
|
+
dec = FakeDecoder()
|
|
200
|
+
dec.transcribe.return_value = "hello"
|
|
201
|
+
v, _, _ = _make_vio(dec)
|
|
202
|
+
v.cfg.model.screen_vocabulary = True
|
|
203
|
+
yield v, dec
|
|
204
|
+
if v._session is not None:
|
|
205
|
+
v._session.cancel()
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class TestDaemon:
|
|
209
|
+
def test_capture_does_not_block_recording_start(self, vio):
|
|
210
|
+
v, dec = vio
|
|
211
|
+
gate = threading.Event()
|
|
212
|
+
captured = threading.Event()
|
|
213
|
+
|
|
214
|
+
def blocked_capture(*_a, **_k):
|
|
215
|
+
gate.wait(5)
|
|
216
|
+
captured.set()
|
|
217
|
+
return ScreenContext(title="Quuxley - Discord")
|
|
218
|
+
|
|
219
|
+
with patch("voiceio.context_terms.capture", side_effect=blocked_capture):
|
|
220
|
+
t0 = time.monotonic()
|
|
221
|
+
v.on_hotkey()
|
|
222
|
+
assert time.monotonic() - t0 < 1.0
|
|
223
|
+
assert v.recorder.is_recording, "recording started while capture blocks"
|
|
224
|
+
assert "Quuxley" not in (v._recording_bias.hotwords or "")
|
|
225
|
+
gate.set()
|
|
226
|
+
assert captured.wait(5)
|
|
227
|
+
for _ in range(100):
|
|
228
|
+
if "Quuxley" in (v._recording_bias.hotwords or ""):
|
|
229
|
+
break
|
|
230
|
+
time.sleep(0.02)
|
|
231
|
+
assert "Quuxley" in (v._recording_bias.hotwords or "")
|
|
232
|
+
assert "Quuxley" in (v._session._bias.hotwords or ""), "applied to later passes"
|
|
233
|
+
|
|
234
|
+
def test_late_capture_never_touches_the_next_recording(self, vio):
|
|
235
|
+
v, _ = vio
|
|
236
|
+
gate = threading.Event()
|
|
237
|
+
with patch("voiceio.context_terms.capture",
|
|
238
|
+
side_effect=lambda *a, **k: (gate.wait(5), ScreenContext(title="Quuxley"))[1]):
|
|
239
|
+
v.on_hotkey()
|
|
240
|
+
rid = v._recording
|
|
241
|
+
v._recording += 1 # as if this recording ended and another began
|
|
242
|
+
gate.set()
|
|
243
|
+
time.sleep(0.3)
|
|
244
|
+
v._recording = rid
|
|
245
|
+
assert "Quuxley" not in (v._recording_bias.hotwords or "")
|
|
246
|
+
|
|
247
|
+
def test_off_reads_nothing(self, vio):
|
|
248
|
+
v, _ = vio
|
|
249
|
+
v.cfg.model.screen_vocabulary = False
|
|
250
|
+
with patch("voiceio.context_terms.capture") as cap:
|
|
251
|
+
v.on_hotkey()
|
|
252
|
+
time.sleep(0.1)
|
|
253
|
+
cap.assert_not_called()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.9.0"
|
|
@@ -168,6 +168,10 @@ class VoiceIO:
|
|
|
168
168
|
threading.Thread(
|
|
169
169
|
target=retention.prune, args=(cfg.data,), daemon=True,
|
|
170
170
|
).start()
|
|
171
|
+
if cfg.model.screen_vocabulary:
|
|
172
|
+
# Word frequency tables load in ~1s; not on the first dictation.
|
|
173
|
+
from voiceio.context_terms import warm
|
|
174
|
+
threading.Thread(target=warm, args=(cfg.model.language,), daemon=True).start()
|
|
171
175
|
|
|
172
176
|
# Hotkey deduplication
|
|
173
177
|
self._hotkey_lock = threading.Lock()
|
|
@@ -557,10 +561,12 @@ class VoiceIO:
|
|
|
557
561
|
# `voiceio vocab`/`corrections` apply from the next recording.
|
|
558
562
|
bias = self.engine.bias()
|
|
559
563
|
self._recording_bias = bias
|
|
560
|
-
if self.cfg.data.capture_context:
|
|
561
|
-
# Snapshot the dictation target now — focus may change by finalize
|
|
564
|
+
if self.cfg.data.capture_context or self.cfg.model.screen_vocabulary:
|
|
565
|
+
# Snapshot the dictation target now — focus may change by finalize.
|
|
566
|
+
# Off this thread: the chime and the mic never wait on it.
|
|
562
567
|
self._context_title = None
|
|
563
|
-
threading.Thread(target=self._capture_context,
|
|
568
|
+
threading.Thread(target=self._capture_context, args=(self._recording,),
|
|
569
|
+
daemon=True, name="screen-context").start()
|
|
564
570
|
self._record_start = time.monotonic()
|
|
565
571
|
self.recorder.start()
|
|
566
572
|
self.recorder.set_on_auto_stop(self._on_auto_stop)
|
|
@@ -627,9 +633,29 @@ class VoiceIO:
|
|
|
627
633
|
# No finalizer will run to release the input source, so do it here.
|
|
628
634
|
self._typers.end(rid)
|
|
629
635
|
|
|
630
|
-
def _capture_context(self) -> None:
|
|
636
|
+
def _capture_context(self, rid: int) -> None:
|
|
631
637
|
from voiceio import retention
|
|
632
|
-
|
|
638
|
+
if not self.cfg.model.screen_vocabulary:
|
|
639
|
+
self._context_title = retention.active_window_title()
|
|
640
|
+
return
|
|
641
|
+
from voiceio import context_terms
|
|
642
|
+
t0 = time.monotonic()
|
|
643
|
+
screen = context_terms.capture()
|
|
644
|
+
if self.cfg.data.capture_context:
|
|
645
|
+
self._context_title = screen.title or None
|
|
646
|
+
terms = self.engine.screen_terms([screen.title, screen.selection])
|
|
647
|
+
if not terms:
|
|
648
|
+
return
|
|
649
|
+
bias = self.engine.bias(screen_terms=terms)
|
|
650
|
+
with self._hotkey_lock:
|
|
651
|
+
if self._recording != rid or self._state != _State.RECORDING:
|
|
652
|
+
return
|
|
653
|
+
self._recording_bias = bias
|
|
654
|
+
if self._session is not None:
|
|
655
|
+
self._session.set_bias(bias)
|
|
656
|
+
# A count only: these are words from the user's screen.
|
|
657
|
+
log.debug("Screen vocabulary: %d term(s) from %s, ready %.0f ms after start",
|
|
658
|
+
len(terms), "+".join(screen.sources()), (time.monotonic() - t0) * 1000)
|
|
633
659
|
|
|
634
660
|
def _retention_extra(self, audio: np.ndarray | None) -> dict:
|
|
635
661
|
"""Save the utterance audio (before transcription, so it survives
|
|
@@ -90,6 +90,10 @@ class ModelConfig:
|
|
|
90
90
|
# "modified_beam_search" (required for hotwords).
|
|
91
91
|
decoding: str = ""
|
|
92
92
|
vocabulary_file: str = "" # path to vocabulary.txt
|
|
93
|
+
# Bias each dictation toward rare names on screen: the focused window's
|
|
94
|
+
# title and the selected text (voiceio/context_terms.py). Read locally
|
|
95
|
+
# when recording starts; never stored or sent anywhere.
|
|
96
|
+
screen_vocabulary: bool = True
|
|
93
97
|
# Beam width of the passes whose text you keep (freezes and the final
|
|
94
98
|
# tail); interim previews are always greedy. 1 is greedy: faster stop.
|
|
95
99
|
beam_size: int = 5
|
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
"""On-screen vocabulary: bias one dictation toward the names you can see.
|
|
2
|
+
|
|
3
|
+
Most misrecognitions are proper nouns and jargon, and the word you are about
|
|
4
|
+
to say is often already on screen: in the focused window's title or in the
|
|
5
|
+
text you just selected. This reads those two sources when a recording starts,
|
|
6
|
+
picks out the terms Whisper is likely to miss (rare words, CamelCase,
|
|
7
|
+
acronyms, multiword names) and hands them to `core.bias` for this one
|
|
8
|
+
recording, inside a small share of the hotword budget.
|
|
9
|
+
|
|
10
|
+
Capture happens off the hotkey path, under a hard deadline, and degrades to
|
|
11
|
+
"no terms" on any failure. Nothing captured here is logged or written to
|
|
12
|
+
disk; only a term count is logged.
|
|
13
|
+
|
|
14
|
+
Not used: AT-SPI (the focused widget's text). It needs PyGObject, which the
|
|
15
|
+
venv can't import, so a system-Python helper; a one-shot walk of the
|
|
16
|
+
accessibility tree took 120-900 ms on GNOME, and Chromium/Electron apps expose
|
|
17
|
+
their text only while a screen reader is on.
|
|
18
|
+
"""
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
import os
|
|
23
|
+
import re
|
|
24
|
+
import shutil
|
|
25
|
+
import subprocess
|
|
26
|
+
import threading
|
|
27
|
+
import time
|
|
28
|
+
from collections.abc import Callable, Iterable
|
|
29
|
+
from dataclasses import dataclass
|
|
30
|
+
|
|
31
|
+
log = logging.getLogger(__name__)
|
|
32
|
+
|
|
33
|
+
# Capture deadline. Long enough for two ~20 ms subprocess reads under load,
|
|
34
|
+
# short enough that the terms are in place before the first interim pass.
|
|
35
|
+
CAPTURE_TIMEOUT_SECS = 0.15
|
|
36
|
+
# Only the head of a large selection is scanned: extraction stays ~10 ms.
|
|
37
|
+
MAX_SCAN_CHARS = 8000
|
|
38
|
+
MAX_TERMS = 8
|
|
39
|
+
|
|
40
|
+
# Zipf thresholds (see wordfreq.py: 2.0 ≈ top 30K words). A capitalized word
|
|
41
|
+
# is a name, and Whisper misses names it has seen far more often than generic
|
|
42
|
+
# rare words (Kosh 2.1, Wayland 2.7), so names get a higher bar.
|
|
43
|
+
_ZIPF_LOWER = 2.0
|
|
44
|
+
_ZIPF_NAME = 3.0
|
|
45
|
+
_ZIPF_MIXED = 3.5
|
|
46
|
+
_ZIPF_COMMON = 4.0
|
|
47
|
+
|
|
48
|
+
_TOKEN = re.compile(r"[\w'’.\-/@#:+]+")
|
|
49
|
+
_WORD = re.compile(r"^[A-Za-zÀ-ɏ][A-Za-zÀ-ɏ'’\-]*[A-Za-zÀ-ɏ]$")
|
|
50
|
+
_MIXED = re.compile(r"[a-zà-ÿ][A-ZÀ-Þ]|[A-ZÀ-Þ]{2}[a-zà-ÿ]")
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
@dataclass(frozen=True)
|
|
54
|
+
class ScreenContext:
|
|
55
|
+
title: str = ""
|
|
56
|
+
selection: str = ""
|
|
57
|
+
secs: float = 0.0
|
|
58
|
+
|
|
59
|
+
def sources(self) -> list[str]:
|
|
60
|
+
return [n for n, v in (("title", self.title), ("selection", self.selection)) if v]
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _kind(word: str, zipf: Callable[[str], float]) -> int:
|
|
64
|
+
"""How much a word looks like something Whisper will miss: 3 = CamelCase
|
|
65
|
+
or rare name, 2 = acronym, 1 = rare lowercase word, 0 = not a term."""
|
|
66
|
+
if not _WORD.match(word) or len(word) < 3:
|
|
67
|
+
return 0
|
|
68
|
+
if _MIXED.search(word):
|
|
69
|
+
return 3 if len(word) <= 20 and zipf(word) < _ZIPF_MIXED else 0
|
|
70
|
+
if word.isupper():
|
|
71
|
+
return 2 if len(word) <= 6 and zipf(word) < _ZIPF_NAME else 0
|
|
72
|
+
if word[0].isupper():
|
|
73
|
+
return 3 if zipf(word) < _ZIPF_NAME else 0
|
|
74
|
+
return 1 if len(word) >= 4 and zipf(word) < _ZIPF_LOWER else 0
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _words(text: str) -> list[list[str]]:
|
|
78
|
+
"""Runs of plain words; an identifier, URL or number ends a run, and its
|
|
79
|
+
letter parts (voiceio.dev → voiceio) stand alone."""
|
|
80
|
+
runs: list[list[str]] = [[]]
|
|
81
|
+
for line in text.splitlines():
|
|
82
|
+
runs.append([])
|
|
83
|
+
for tok in _TOKEN.findall(line):
|
|
84
|
+
stripped = tok.strip("'’.-:")
|
|
85
|
+
if _WORD.match(stripped):
|
|
86
|
+
runs[-1].append(stripped)
|
|
87
|
+
if tok.rstrip("'’") != stripped: # trailing punctuation ends the run
|
|
88
|
+
runs.append([])
|
|
89
|
+
continue
|
|
90
|
+
runs.append([])
|
|
91
|
+
for part in re.split(r"[^A-Za-zÀ-ɏ'’]+", tok):
|
|
92
|
+
if _WORD.match(part):
|
|
93
|
+
runs[-1].append(part)
|
|
94
|
+
runs.append([])
|
|
95
|
+
return [r for r in runs if r]
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _name_end(run: list[str], kinds: list[int], i: int,
|
|
99
|
+
zipf: Callable[[str], float]) -> int:
|
|
100
|
+
"""End of a multiword name starting at run[i] (2-3 capitalized words,
|
|
101
|
+
one of them rare), or i when there is none. Very common words don't
|
|
102
|
+
start or end a name: "Hey Kosh" is a greeting, not a name."""
|
|
103
|
+
j = i
|
|
104
|
+
while j < len(run) and j - i < 3 and run[j][0].isupper() and not run[j].isupper():
|
|
105
|
+
j += 1
|
|
106
|
+
if j - i < 2 or zipf(run[i]) >= _ZIPF_COMMON or run[i] == run[i + 1]:
|
|
107
|
+
return i
|
|
108
|
+
while j - i >= 2 and zipf(run[j - 1]) >= _ZIPF_COMMON:
|
|
109
|
+
j -= 1
|
|
110
|
+
return j if j - i >= 2 and any(k == 3 for k in kinds[i:j]) else i
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def extract_terms(
|
|
114
|
+
texts: Iterable[str],
|
|
115
|
+
*,
|
|
116
|
+
language: str = "en",
|
|
117
|
+
exclude: Iterable[str] = (),
|
|
118
|
+
prefer: Iterable[str] = (),
|
|
119
|
+
max_terms: int = MAX_TERMS,
|
|
120
|
+
) -> list[str]:
|
|
121
|
+
"""Candidate hotwords from on-screen text, best first.
|
|
122
|
+
|
|
123
|
+
`texts` in priority order (title before selection). `exclude` holds terms
|
|
124
|
+
already biased (case-insensitive); `prefer` the user's full vocabulary,
|
|
125
|
+
whose long tail never fits the budget — seeing one of them on screen is
|
|
126
|
+
the strongest signal there is.
|
|
127
|
+
"""
|
|
128
|
+
from functools import lru_cache
|
|
129
|
+
|
|
130
|
+
from voiceio.wordfreq import _zipf
|
|
131
|
+
|
|
132
|
+
zipf = lru_cache(maxsize=4096)(lambda w: _zipf(w, language))
|
|
133
|
+
excluded = {t.lower() for t in exclude}
|
|
134
|
+
preferred = {t.lower() for t in prefer}
|
|
135
|
+
# lowercased term → [score, first seen, spelling as first seen]
|
|
136
|
+
scores: dict[str, list] = {}
|
|
137
|
+
|
|
138
|
+
def add(term: str, score: float) -> None:
|
|
139
|
+
key = term.lower()
|
|
140
|
+
if key in excluded:
|
|
141
|
+
return
|
|
142
|
+
if key in scores:
|
|
143
|
+
# Repeats add a little, so the term the page is about outranks a
|
|
144
|
+
# one-off, without letting a long page flood the list.
|
|
145
|
+
scores[key][0] += 0.5
|
|
146
|
+
return
|
|
147
|
+
scores[key] = [score + (3 if key in preferred else 0), len(scores), term]
|
|
148
|
+
|
|
149
|
+
texts = list(texts)
|
|
150
|
+
for rank, text in enumerate(texts):
|
|
151
|
+
weight = (len(texts) - rank) * 0.1 # earlier source wins ties
|
|
152
|
+
for run in _words((text or "")[:MAX_SCAN_CHARS]):
|
|
153
|
+
kinds = [_kind(w, zipf) for w in run]
|
|
154
|
+
i = 0
|
|
155
|
+
while i < len(run):
|
|
156
|
+
j = _name_end(run, kinds, i, zipf)
|
|
157
|
+
if j > i + 1:
|
|
158
|
+
add(" ".join(run[i:j]), 3 + weight)
|
|
159
|
+
i = j
|
|
160
|
+
continue
|
|
161
|
+
if kinds[i]:
|
|
162
|
+
add(run[i], kinds[i] + weight)
|
|
163
|
+
i += 1
|
|
164
|
+
|
|
165
|
+
ranked = sorted(scores.values(), key=lambda v: (-v[0], v[1]))
|
|
166
|
+
out: list[str] = []
|
|
167
|
+
seen_words: set[str] = set()
|
|
168
|
+
for _, _, term in ranked:
|
|
169
|
+
parts = term.lower().split()
|
|
170
|
+
if seen_words.intersection(parts):
|
|
171
|
+
continue # a name and its parts cost tokens twice for one word
|
|
172
|
+
out.append(term)
|
|
173
|
+
seen_words.update(parts)
|
|
174
|
+
if len(out) >= max_terms:
|
|
175
|
+
break
|
|
176
|
+
return out
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def warm(language: str = "en") -> None:
|
|
180
|
+
"""Load the word frequency tables now, not on the first dictation."""
|
|
181
|
+
extract_terms(["Warmup"], language=language)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
# ── capture ────────────────────────────────────────────────────────────────
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _read_primary(timeout: float) -> str:
|
|
188
|
+
"""The PRIMARY selection (highlighted text). xclip through XWayland first:
|
|
189
|
+
on GNOME, wl-paste can take keyboard focus for a frame, which drops an
|
|
190
|
+
IBus preedit — never acceptable at the start of a dictation."""
|
|
191
|
+
from voiceio.platform import detect
|
|
192
|
+
p = detect()
|
|
193
|
+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
|
|
194
|
+
argv = ["xclip", "-o", "-selection", "primary"]
|
|
195
|
+
elif p.is_wayland and p.desktop != "gnome" and shutil.which("wl-paste"):
|
|
196
|
+
argv = ["wl-paste", "-p", "--no-newline"]
|
|
197
|
+
else:
|
|
198
|
+
return ""
|
|
199
|
+
try:
|
|
200
|
+
out = subprocess.run(argv, capture_output=True, timeout=timeout)
|
|
201
|
+
except (subprocess.TimeoutExpired, OSError):
|
|
202
|
+
return ""
|
|
203
|
+
if out.returncode != 0:
|
|
204
|
+
return ""
|
|
205
|
+
return out.stdout[:MAX_SCAN_CHARS * 4].decode("utf-8", "replace")
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
def _read_title(_timeout: float) -> str:
|
|
209
|
+
from voiceio import retention
|
|
210
|
+
return retention.active_window_title() or ""
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def capture(timeout: float = CAPTURE_TIMEOUT_SECS, *,
|
|
214
|
+
readers: dict[str, Callable[[float], str]] | None = None) -> ScreenContext:
|
|
215
|
+
"""Read the window title and selection in parallel; whatever isn't back
|
|
216
|
+
by `timeout` is dropped (its thread finishes on its own). Never raises."""
|
|
217
|
+
readers = readers or {"title": _read_title, "selection": _read_primary}
|
|
218
|
+
t0 = time.monotonic()
|
|
219
|
+
results: dict[str, str] = {}
|
|
220
|
+
|
|
221
|
+
def run(name: str, fn: Callable[[float], str]) -> None:
|
|
222
|
+
try:
|
|
223
|
+
results[name] = fn(timeout) or ""
|
|
224
|
+
except Exception: # noqa: BLE001 — context is a bonus
|
|
225
|
+
log.debug("screen context: %s failed", name, exc_info=True)
|
|
226
|
+
|
|
227
|
+
threads = [threading.Thread(target=run, args=item, daemon=True,
|
|
228
|
+
name=f"screen-{item[0]}") for item in readers.items()]
|
|
229
|
+
for t in threads:
|
|
230
|
+
t.start()
|
|
231
|
+
deadline = t0 + timeout
|
|
232
|
+
for t in threads:
|
|
233
|
+
t.join(max(0.0, deadline - time.monotonic()))
|
|
234
|
+
got = dict(results) # snapshot: a late reader must not change it
|
|
235
|
+
return ScreenContext(title=got.get("title", ""), selection=got.get("selection", ""),
|
|
236
|
+
secs=time.monotonic() - t0)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def describe() -> str:
|
|
240
|
+
"""For `voiceio doctor`: which sources this session can read."""
|
|
241
|
+
from voiceio.focus import focused_window
|
|
242
|
+
from voiceio.platform import detect
|
|
243
|
+
p = detect()
|
|
244
|
+
sources = []
|
|
245
|
+
if os.environ.get("DISPLAY") and shutil.which("xclip"):
|
|
246
|
+
sources.append("selection (xclip)")
|
|
247
|
+
elif p.is_wayland and p.desktop != "gnome" and shutil.which("wl-paste"):
|
|
248
|
+
sources.append("selection (wl-paste)")
|
|
249
|
+
if focused_window(xwayland=True) is not None:
|
|
250
|
+
sources.append("window title")
|
|
251
|
+
elif p.is_wayland and p.desktop in ("gnome", "kde"):
|
|
252
|
+
sources.append("window title of X11 apps only")
|
|
253
|
+
return ", ".join(sources) or "no source available"
|