python-voiceio 1.2.0__tar.gz → 1.3.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.2.0/python_voiceio.egg-info → python_voiceio-1.3.0}/PKG-INFO +4 -3
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/README.md +3 -2
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/pyproject.toml +1 -1
- {python_voiceio-1.2.0 → python_voiceio-1.3.0/python_voiceio.egg-info}/PKG-INFO +4 -3
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_pipeline.py +61 -3
- python_voiceio-1.3.0/tests/test_learn_review.py +44 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_service.py +36 -5
- python_voiceio-1.3.0/voiceio/__init__.py +1 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/app.py +8 -1
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/learn.py +48 -16
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/servicecmd.py +29 -17
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/health.py +2 -1
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/evaluate.py +15 -5
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/review.py +30 -9
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/pidlock.py +18 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/service.py +19 -4
- python_voiceio-1.2.0/tests/test_learn_review.py +0 -20
- python_voiceio-1.2.0/voiceio/__init__.py +0 -1
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/LICENSE +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/SOURCES.txt +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/dependency_links.txt +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/entry_points.txt +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/requires.txt +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/python_voiceio.egg-info/top_level.txt +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/setup.cfg +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_app_wiring.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_audio_quality.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_audio_source.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_backend_probes.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_cli.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_clipboard_read.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_commands.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_concurrency_lockdown.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_config.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_corrections.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_decoders.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_engine.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_evdev_uaccess.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_fallback.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_fcitx_bridge.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_health.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_hints.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_history.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_engine_limits.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_engine_listener.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_pending.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_pids.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_ping.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_session.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_textlimit.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_ibus_typer.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_clean.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_import.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_metrics.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_learn_targets.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_llm.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_llm_api.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_numbers.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_pipeline.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_platform.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_postcorrect.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_postprocess.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_prebuffer.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_prompt.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_recorder_integration.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_retention.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_robustness.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_security_hardening.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_server.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_setup.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_streaming.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_suggest.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_tokens.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_transcriber.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_tts.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_typer_manager.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_vad.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_vocabulary.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_wordfreq.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/tests/test_worker_decode.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/__main__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/audio_source.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/backends.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/configcmd.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/corrections.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/doctor.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/history.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/models.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/uninstall.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/cli/vocab.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/clipboard_read.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/commands.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/config.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/consent.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/bias.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/base.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/catalog.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/faster_whisper/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/faster_whisper/worker.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/lanes.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/sherpa.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/decoders/whispercpp.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/engine.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/core/pipeline.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/corrections.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/data/60-voiceio-uaccess.rules +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/data/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/demo.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/display.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/feedback.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hints.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/history.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/base.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/chain.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/evdev.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/pynput_backend.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/hotkeys/socket_backend.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/engine.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/install.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/pending.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/session.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ibus/textlimit.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/augment.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/clean.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/importer.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/metrics.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/store.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/teacher.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/learn/train.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/llm.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/llm_api.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/models/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/models/silero_vad.onnx +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/numbers.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/platform.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/postcorrect.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/postprocess.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/privacy.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/prompt.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/recorder.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/retention.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/__main__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/app.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/config.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/server/runtime.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/configfile.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/extras.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/hotkey.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/noninteractive.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/speech.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/steps.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/system.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/setup/ui.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/commit.wav +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/start.wav +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/sounds/stop.wav +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/streaming.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/suggest.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tokens.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/_icons.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/_indicator.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tray/_pystray.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/base.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/chain.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/edge_engine.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/espeak.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/piper_engine.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/tts/player.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/__init__.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/base.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/chain.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/clipboard.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/collecting.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/fcitx_bridge.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/ibus.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/manager.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/pynput_type.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/wtype.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/xdotool.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/typers/ydotool.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/ui.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/vad.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/vocab_stats.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/vocabulary.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/watchdog.py +0 -0
- {python_voiceio-1.2.0 → python_voiceio-1.3.0}/voiceio/wordfreq.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Voice dictation for Linux. Speak → text, locally, instantly.
|
|
5
5
|
Author: Hugo Montenegro
|
|
6
6
|
License-Expression: MIT
|
|
@@ -182,7 +182,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
|
|
|
182
182
|
voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
|
|
183
183
|
voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
|
|
184
184
|
voiceio learn review # fix the teacher where it matters: your names and terms
|
|
185
|
-
voiceio learn review --test #
|
|
185
|
+
voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
|
|
186
|
+
# where the teacher and your live decode disagree, quickest clips first
|
|
186
187
|
voiceio learn augment # synthesize extra examples of rare terms with local TTS
|
|
187
188
|
voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
|
|
188
189
|
voiceio learn eval # score the current model on your held-out clips: WER and term recall
|
|
@@ -191,6 +192,7 @@ voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only
|
|
|
191
192
|
voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
|
|
192
193
|
voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
|
|
193
194
|
voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
|
|
195
|
+
# (--auto-promote: switch to a winner by itself; `learn rollback` undoes it)
|
|
194
196
|
```
|
|
195
197
|
|
|
196
198
|
Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
|
|
@@ -301,7 +303,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
|
|
|
301
303
|
## Roadmap
|
|
302
304
|
|
|
303
305
|
- **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
|
|
304
|
-
- **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
|
|
305
306
|
- **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
|
|
306
307
|
- **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
|
|
307
308
|
|
|
@@ -129,7 +129,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
|
|
|
129
129
|
voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
|
|
130
130
|
voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
|
|
131
131
|
voiceio learn review # fix the teacher where it matters: your names and terms
|
|
132
|
-
voiceio learn review --test #
|
|
132
|
+
voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
|
|
133
|
+
# where the teacher and your live decode disagree, quickest clips first
|
|
133
134
|
voiceio learn augment # synthesize extra examples of rare terms with local TTS
|
|
134
135
|
voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
|
|
135
136
|
voiceio learn eval # score the current model on your held-out clips: WER and term recall
|
|
@@ -138,6 +139,7 @@ voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only
|
|
|
138
139
|
voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
|
|
139
140
|
voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
|
|
140
141
|
voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
|
|
142
|
+
# (--auto-promote: switch to a winner by itself; `learn rollback` undoes it)
|
|
141
143
|
```
|
|
142
144
|
|
|
143
145
|
Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
|
|
@@ -248,7 +250,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
|
|
|
248
250
|
## Roadmap
|
|
249
251
|
|
|
250
252
|
- **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
|
|
251
|
-
- **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
|
|
252
253
|
- **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
|
|
253
254
|
- **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
|
|
254
255
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-voiceio
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.3.0
|
|
4
4
|
Summary: Voice dictation for Linux. Speak → text, locally, instantly.
|
|
5
5
|
Author: Hugo Montenegro
|
|
6
6
|
License-Expression: MIT
|
|
@@ -182,7 +182,8 @@ Most dictation errors are proper nouns and jargon: `PostgreSQL` heard as `post g
|
|
|
182
182
|
voiceio learn import DIR --source phone # optional: add your own recordings from elsewhere
|
|
183
183
|
voiceio learn label # a stronger teacher model (large-v3-turbo) transcribes your clips
|
|
184
184
|
voiceio learn review # fix the teacher where it matters: your names and terms
|
|
185
|
-
voiceio learn review --test #
|
|
185
|
+
voiceio learn review --test # verify the held-out clips, so eval scores against the truth: it asks only
|
|
186
|
+
# where the teacher and your live decode disagree, quickest clips first
|
|
186
187
|
voiceio learn augment # synthesize extra examples of rare terms with local TTS
|
|
187
188
|
voiceio learn build # freeze a versioned train/dev/test dataset (the test split never trains)
|
|
188
189
|
voiceio learn eval # score the current model on your held-out clips: WER and term recall
|
|
@@ -191,6 +192,7 @@ voiceio learn train # LoRA fine-tune (needs the `train` extra; decoder-only
|
|
|
191
192
|
voiceio learn promote <run> # dictate with the fine-tune; `voiceio learn rollback` undoes it
|
|
192
193
|
voiceio learn maintain # later: label what's new, and once 50+ clips piled up, retrain and compare
|
|
193
194
|
voiceio learn schedule on # …or let it run `maintain` every night, and notify you when a model wins
|
|
195
|
+
# (--auto-promote: switch to a winner by itself; `learn rollback` undoes it)
|
|
194
196
|
```
|
|
195
197
|
|
|
196
198
|
Labeling, training and offline eval run in an idle-priority cgroup, so they never slow dictation down. Everything stays on your machine unless you choose `train --remote`.
|
|
@@ -301,7 +303,6 @@ Logs: `journalctl --user -u voiceio` or `~/.local/state/voiceio/voiceio.log`.
|
|
|
301
303
|
## Roadmap
|
|
302
304
|
|
|
303
305
|
- **A landing page** — voiceio as local, open-source, private dictation that learns from you. A draft is in [`site/index.html`](site/index.html), ready for GitHub Pages.
|
|
304
|
-
- **Hands-off promotion** — `learn schedule` already retrains nightly and tells you when a model wins; next is switching to it automatically, with one-command rollback.
|
|
305
306
|
- **Unbounded vocabulary** — move to a decoder whose biasing has no token cap (see [model choice](CONTRIBUTING.md#model-choice-why-whisper-and-what-would-change-it)).
|
|
306
307
|
- **One model everywhere** — sync your fine-tune to your phone server and laptops, peer to peer.
|
|
307
308
|
|
|
@@ -132,14 +132,34 @@ def test_learn_jobs_reexec_into_an_idle_scope(monkeypatch):
|
|
|
132
132
|
|
|
133
133
|
|
|
134
134
|
def test_promotion_requires_a_winning_eval():
|
|
135
|
-
base = {"wer": 0.10, "term_recall": 0.40, "repetitions": 0}
|
|
136
|
-
better = {"wer": 0.101, "term_recall": 0.70, "repetitions": 0} # vocab win
|
|
137
|
-
worse = {"wer": 0.13, "term_recall": 0.70, "repetitions": 0}
|
|
135
|
+
base = {"wer": 0.10, "term_recall": 0.40, "repetitions": 0, "clips": 44}
|
|
136
|
+
better = {"wer": 0.101, "term_recall": 0.70, "repetitions": 0, "clips": 44} # vocab win
|
|
137
|
+
worse = {"wer": 0.13, "term_recall": 0.70, "repetitions": 0, "clips": 44}
|
|
138
138
|
assert evaluate.promotion_verdict(better, base)[0]
|
|
139
139
|
assert not evaluate.promotion_verdict(worse, base)[0]
|
|
140
140
|
assert not evaluate.promotion_verdict({**better, "repetitions": 2}, base)[0]
|
|
141
141
|
|
|
142
142
|
|
|
143
|
+
def test_no_promotion_without_evidence():
|
|
144
|
+
"""A crashed decode scores 0% WER; an empty test split scores nothing."""
|
|
145
|
+
base = {"wer": 0.10, "term_recall": 0.40, "repetitions": 0, "clips": 44}
|
|
146
|
+
perfect = {"wer": 0.0, "term_recall": 1.0, "repetitions": 0}
|
|
147
|
+
assert not evaluate.promotion_verdict({**perfect, "clips": 44, "failures": 3}, base)[0]
|
|
148
|
+
assert not evaluate.promotion_verdict({**perfect, "clips": 40}, base)[0] # other clips
|
|
149
|
+
assert not evaluate.promotion_verdict({**perfect, "clips": 2}, {**base, "clips": 2})[0]
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def test_latest_finds_runs_at_the_configured_beam():
|
|
153
|
+
runs = store.learn_dir() / "runs"
|
|
154
|
+
runs.mkdir(parents=True)
|
|
155
|
+
for i, (dec, wer) in enumerate([("faster-whisper:small", 5), ("faster-whisper:small@beam2+draft", 2)]):
|
|
156
|
+
(runs / f"eval-2026010{i}.json").write_text(json.dumps(
|
|
157
|
+
{"decoder": dec, "dataset": "v0001", "wer": wer, "metrics": metrics.METRICS_VERSION}))
|
|
158
|
+
assert evaluate.latest("small", "v0001")["wer"] == 5
|
|
159
|
+
assert evaluate.latest("small", "v0001", beam=2)["wer"] == 2
|
|
160
|
+
assert evaluate.latest("small", "v0001", beam=1) is None
|
|
161
|
+
|
|
162
|
+
|
|
143
163
|
def test_latest_eval_matches_model_dataset_and_mode():
|
|
144
164
|
runs = store.learn_dir() / "runs"
|
|
145
165
|
runs.mkdir(parents=True)
|
|
@@ -308,3 +328,41 @@ def test_wait_summary_p90_never_exceeds_max():
|
|
|
308
328
|
s = r.wait_summary()
|
|
309
329
|
p90 = float(s.split("p90 ")[1].split("s")[0])
|
|
310
330
|
assert p90 <= 6.83
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def test_maintain_auto_promote_switches_restarts_and_says_how_to_undo(monkeypatch):
|
|
334
|
+
import argparse
|
|
335
|
+
from types import SimpleNamespace
|
|
336
|
+
|
|
337
|
+
from voiceio import feedback, service
|
|
338
|
+
from voiceio.cli import learn as cli
|
|
339
|
+
from voiceio.learn import evaluate
|
|
340
|
+
for i in range(3):
|
|
341
|
+
_clip(f"{i}.wav", 3, "x")
|
|
342
|
+
store.add_label(f"{i}.wav", [Segment(0, 3, "hello there")], "teacher:fake")
|
|
343
|
+
run = store.learn_dir() / "models" / "v0001-small-x"
|
|
344
|
+
calls = []
|
|
345
|
+
monkeypatch.setattr(cli, "cmd_label", lambda a: None)
|
|
346
|
+
monkeypatch.setattr(train, "missing_train_deps", lambda: [])
|
|
347
|
+
monkeypatch.setattr(cli, "_run_eval", lambda *a, **k: SimpleNamespace(
|
|
348
|
+
wer=0.1, term_recall=0.5, repetitions=0, per_clip=[]))
|
|
349
|
+
monkeypatch.setattr(cli, "_train", lambda *a, **k: run)
|
|
350
|
+
monkeypatch.setattr(evaluate, "promotion_verdict", lambda c, b: (True, "table"))
|
|
351
|
+
monkeypatch.setattr(cli, "cmd_promote", lambda a: calls.append(("promote", a.run)))
|
|
352
|
+
monkeypatch.setattr(service, "is_installed", lambda: True)
|
|
353
|
+
monkeypatch.setattr(service, "restart_when_idle", lambda: calls.append(("restart",)) or True)
|
|
354
|
+
monkeypatch.setattr(feedback, "notify", lambda t, b, urgent=False: calls.append(("notify", b)))
|
|
355
|
+
|
|
356
|
+
cli.cmd_maintain(argparse.Namespace(min_new=1, base="small", epochs=1, auto_promote=True))
|
|
357
|
+
assert calls[:2] == [("promote", "v0001-small-x"), ("restart",)]
|
|
358
|
+
assert "voiceio learn rollback" in calls[2][1]
|
|
359
|
+
|
|
360
|
+
# The gate's own re-check refuses: no restart, no "switched" notification,
|
|
361
|
+
# and no crash (the nightly unit must not fail).
|
|
362
|
+
calls.clear()
|
|
363
|
+
|
|
364
|
+
def refuse(a):
|
|
365
|
+
raise SystemExit("evaluate both models first")
|
|
366
|
+
monkeypatch.setattr(cli, "cmd_promote", refuse)
|
|
367
|
+
cli.cmd_maintain(argparse.Namespace(min_new=1, base="small", epochs=1, auto_promote=True))
|
|
368
|
+
assert calls == []
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
|
|
2
|
+
|
|
3
|
+
def test_queue_all_covers_every_segment_of_unreviewed_clips():
|
|
4
|
+
from voiceio.learn import review
|
|
5
|
+
from voiceio.learn.store import Clip, Label, Segment
|
|
6
|
+
a = Clip("a.wav", 0, 4, "", "")
|
|
7
|
+
b = Clip("b.wav", 0, 4, "", "")
|
|
8
|
+
segs = (Segment(0, 2, "one"), Segment(2, 4, "two"))
|
|
9
|
+
labels = {"a.wav": Label("a.wav", segs, "teacher:x", 0),
|
|
10
|
+
"b.wav": Label("b.wav", segs, "human", 0)}
|
|
11
|
+
items = review.queue_all([a, b], labels)
|
|
12
|
+
assert [(i.clip.audio, i.index) for i in items] == [("a.wav", 0), ("a.wav", 1)]
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_live_span_shows_the_live_decode_only_where_it_differs():
|
|
16
|
+
from voiceio.learn.review import live_span
|
|
17
|
+
live = "okay so we use kalchi daily and then more stuff"
|
|
18
|
+
assert live_span("We use Kalshi daily.", live) == "we use kalchi daily"
|
|
19
|
+
assert live_span("We use Kalshi daily.", "so we use Kalshi daily ok") is None
|
|
20
|
+
assert live_span("totally different words here", "nothing alike at all") is None
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def test_gold_review_asks_only_where_the_models_disagree_cheapest_clip_first():
|
|
24
|
+
from voiceio.learn import review
|
|
25
|
+
from voiceio.learn.store import Clip, Label, Segment
|
|
26
|
+
segs = (Segment(0, 2, "we use Kalshi daily"), Segment(2, 4, "ship it today"))
|
|
27
|
+
two = Clip("two.wav", 0, 4, "we use kalchi daily ship it to day", "")
|
|
28
|
+
one = Clip("one.wav", 0, 4, "we use kalchi daily ship it today", "")
|
|
29
|
+
agree = Clip("agree.wav", 0, 4, "we use Kalshi daily ship it today", "")
|
|
30
|
+
labels = {c.audio: Label(c.audio, segs, "teacher:x", 0) for c in (two, one, agree)}
|
|
31
|
+
items = review.queue_all([two, one, agree], labels)
|
|
32
|
+
assert [(i.clip.audio, i.index) for i in items] == [
|
|
33
|
+
("one.wav", 0), # one answer completes a gold clip
|
|
34
|
+
("two.wav", 0), ("two.wav", 1),
|
|
35
|
+
] # agree.wav: nothing to ask
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_gold_review_counts_one_misheard_term_as_disagreement():
|
|
39
|
+
from voiceio.learn import review
|
|
40
|
+
from voiceio.learn.store import Clip, Label, Segment
|
|
41
|
+
segs = (Segment(0, 4, "we ship the Kalshi bot every single day"),)
|
|
42
|
+
clip = Clip("a.wav", 0, 4, "we ship the kalchi bot every single day", "")
|
|
43
|
+
items = review.queue_all([clip], {"a.wav": Label("a.wav", segs, "teacher:x", 0)})
|
|
44
|
+
assert len(items) == 1
|
|
@@ -48,15 +48,46 @@ def test_learn_timer_runs_maintain_at_idle_priority(monkeypatch, tmp_path):
|
|
|
48
48
|
def test_daemon_publishes_state_and_restart_waits_for_idle(monkeypatch):
|
|
49
49
|
import threading
|
|
50
50
|
|
|
51
|
-
from voiceio import config
|
|
51
|
+
from voiceio import config, pidlock
|
|
52
52
|
from voiceio.app import VoiceIO, _State
|
|
53
53
|
from voiceio.cli.servicecmd import wait_until_idle
|
|
54
54
|
|
|
55
|
-
assert wait_until_idle(timeout=1) # not running: no wait
|
|
56
55
|
vio = VoiceIO.__new__(VoiceIO)
|
|
56
|
+
vio._state = _State.RECORDING # not the lock holder:
|
|
57
|
+
assert not config.STATE_PATH.exists() # says nothing
|
|
58
|
+
vio._publishes_state = True
|
|
57
59
|
vio._state = _State.RECORDING
|
|
58
60
|
assert config.STATE_PATH.read_text() == "recording"
|
|
61
|
+
|
|
62
|
+
monkeypatch.setattr(pidlock, "daemon_alive", lambda path: False)
|
|
63
|
+
assert wait_until_idle(timeout=5) # dead daemon: stale file, no wait
|
|
64
|
+
monkeypatch.setattr(pidlock, "daemon_alive", lambda path: True)
|
|
59
65
|
threading.Timer(0.3, lambda: setattr(vio, "_state", _State.IDLE)).start()
|
|
60
|
-
assert wait_until_idle(timeout=5, poll=0.05)
|
|
61
|
-
vio._state = _State.FINALIZING
|
|
62
|
-
assert not wait_until_idle(timeout=
|
|
66
|
+
assert wait_until_idle(timeout=5, poll=0.05) # waited for the dictation
|
|
67
|
+
vio._state = _State.FINALIZING # a hung finalize: short budget
|
|
68
|
+
assert not wait_until_idle(timeout=60, poll=0.05, finalizing_timeout=0.2)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def test_daemon_alive_probes_the_pid_lock(tmp_path):
|
|
72
|
+
import fcntl
|
|
73
|
+
|
|
74
|
+
from voiceio.pidlock import daemon_alive
|
|
75
|
+
path = tmp_path / "voiceio.pid"
|
|
76
|
+
assert not daemon_alive(path)
|
|
77
|
+
with open(path, "a") as held:
|
|
78
|
+
fcntl.flock(held, fcntl.LOCK_EX)
|
|
79
|
+
assert daemon_alive(path)
|
|
80
|
+
assert not daemon_alive(path)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_learn_timer_auto_promote_is_opt_in(monkeypatch, tmp_path):
|
|
84
|
+
monkeypatch.setattr(service, "has_systemd", lambda: True)
|
|
85
|
+
monkeypatch.setattr(service, "_systemctl", lambda *a: True)
|
|
86
|
+
monkeypatch.setattr(service, "_find_voiceio_bin", lambda: "voiceio")
|
|
87
|
+
monkeypatch.setattr(service, "LEARN_SERVICE_PATH", tmp_path / "s.service")
|
|
88
|
+
monkeypatch.setattr(service, "LEARN_TIMER_PATH", tmp_path / "s.timer")
|
|
89
|
+
service.install_learn_timer()
|
|
90
|
+
assert not service.learn_timer_auto_promotes()
|
|
91
|
+
service.install_learn_timer(auto_promote=True)
|
|
92
|
+
assert "learn maintain --auto-promote" in service.LEARN_SERVICE_PATH.read_text()
|
|
93
|
+
assert service.learn_timer_auto_promotes()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
__version__ = "1.3.0"
|
|
@@ -43,6 +43,10 @@ class VoiceIO:
|
|
|
43
43
|
@_state.setter
|
|
44
44
|
def _state(self, value: _State) -> None:
|
|
45
45
|
self.__state = value
|
|
46
|
+
# Only the instance holding the PID lock speaks for the daemon: a
|
|
47
|
+
# second launch must not overwrite the running one's state.
|
|
48
|
+
if not getattr(self, "_publishes_state", False):
|
|
49
|
+
return
|
|
46
50
|
try: # best-effort: only `service restart` reads it
|
|
47
51
|
tmp = config.STATE_PATH.with_suffix(".tmp")
|
|
48
52
|
tmp.write_text(value.value)
|
|
@@ -660,7 +664,7 @@ class VoiceIO:
|
|
|
660
664
|
self._typers.redetect_at_start()
|
|
661
665
|
|
|
662
666
|
LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
663
|
-
self._pid_fd = open(PID_PATH, "w"
|
|
667
|
+
self._pid_fd = open(PID_PATH, "a") # not "w": the running daemon's PID stays
|
|
664
668
|
try:
|
|
665
669
|
from voiceio.pidlock import lock_pid_file
|
|
666
670
|
lock_pid_file(self._pid_fd)
|
|
@@ -669,8 +673,11 @@ class VoiceIO:
|
|
|
669
673
|
log.error("Another voiceio instance is already running")
|
|
670
674
|
print("voiceio is already running. Stop it first: voiceio service stop")
|
|
671
675
|
return
|
|
676
|
+
self._pid_fd.truncate(0)
|
|
672
677
|
self._pid_fd.write(str(os.getpid()))
|
|
673
678
|
self._pid_fd.flush()
|
|
679
|
+
self._publishes_state = True
|
|
680
|
+
self._state = self._state
|
|
674
681
|
|
|
675
682
|
self._typers.prepare()
|
|
676
683
|
|
|
@@ -84,10 +84,14 @@ def register(sub: argparse._SubParsersAction) -> None:
|
|
|
84
84
|
help="new labeled clips needed before retraining (default 50)")
|
|
85
85
|
mt.add_argument("--base", default="small")
|
|
86
86
|
mt.add_argument("--epochs", type=int, default=4)
|
|
87
|
+
mt.add_argument("--auto-promote", action="store_true",
|
|
88
|
+
help="switch to the fine-tune when it wins, and restart the daemon")
|
|
87
89
|
mt.set_defaults(func=cmd_maintain)
|
|
88
90
|
|
|
89
91
|
sc = lsub.add_parser("schedule", help="Run `maintain` every night (systemd timer)")
|
|
90
92
|
sc.add_argument("state", nargs="?", choices=("on", "off"), help="omit to show")
|
|
93
|
+
sc.add_argument("--auto-promote", action="store_true",
|
|
94
|
+
help="with on: switch to a fine-tune that wins, without asking")
|
|
91
95
|
sc.set_defaults(func=cmd_schedule)
|
|
92
96
|
|
|
93
97
|
pr = lsub.add_parser("promote", help="Use a trained model for dictation")
|
|
@@ -182,8 +186,7 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
182
186
|
print(f"models {', '.join(runs)}")
|
|
183
187
|
active = _cfg().model.name
|
|
184
188
|
print(f"active {active}")
|
|
185
|
-
|
|
186
|
-
print(f"nightly {'on' if service.learn_timer_enabled() else 'off (voiceio learn schedule on)'}")
|
|
189
|
+
print(f"nightly {_schedule_state()}")
|
|
187
190
|
promotable = _print_scores(ds, active, runs) if ds else None
|
|
188
191
|
if promotable:
|
|
189
192
|
nxt = f"promote {promotable}"
|
|
@@ -197,9 +200,9 @@ def _print_scores(ds: Path, active: str, runs: list[str]) -> str | None:
|
|
|
197
200
|
"""Scores on `ds` for the active model and each fine-tune; returns the
|
|
198
201
|
run that beats the active model on everything, if any."""
|
|
199
202
|
from voiceio.learn import evaluate
|
|
200
|
-
current = evaluate.latest(active, ds.name)
|
|
203
|
+
current = evaluate.latest(active, ds.name, beam=_cfg().model.beam_size)
|
|
201
204
|
rows = [(active + " (active)", current)] + [
|
|
202
|
-
(run, evaluate.latest(str(_models_dir() / run / "ct2"), ds.name)) for run in runs]
|
|
205
|
+
(run, evaluate.latest(str(_models_dir() / run / "ct2"), ds.name, beam=_cfg().model.beam_size)) for run in runs]
|
|
203
206
|
if not any(r for _, r in rows):
|
|
204
207
|
return None
|
|
205
208
|
print(f"\nscores on {ds.name} test clips:")
|
|
@@ -442,8 +445,9 @@ def cmd_maintain(args: argparse.Namespace) -> None:
|
|
|
442
445
|
labeled = {c.audio for c in store.clips() if c.audio in labels}
|
|
443
446
|
ds = store.latest_dataset()
|
|
444
447
|
new = len(labeled - store.dataset_clips(ds)) if ds else len(labeled)
|
|
445
|
-
if
|
|
446
|
-
|
|
448
|
+
if new < args.min_new: # the first dataset too: a handful of clips trains nothing
|
|
449
|
+
since = f" since {ds.name}" if ds else ""
|
|
450
|
+
print(f"{new} new labeled clips{since}; retraining waits for {args.min_new}.")
|
|
447
451
|
return
|
|
448
452
|
ds = store.build_dataset()
|
|
449
453
|
print(f"built {ds.name} ({new} new clips)")
|
|
@@ -454,13 +458,39 @@ def cmd_maintain(args: argparse.Namespace) -> None:
|
|
|
454
458
|
print(f"\n{_cfg().model.name} → {run.name} on {ds.name}:\n{table}")
|
|
455
459
|
print(f"better or equal on everything — `voiceio learn promote {run.name}`" if ok
|
|
456
460
|
else "not better on everything — keeping the current model")
|
|
457
|
-
if ok:
|
|
458
|
-
|
|
461
|
+
if not ok:
|
|
462
|
+
return
|
|
463
|
+
# Often runs unattended at night: say so where it will be seen.
|
|
464
|
+
from voiceio import feedback
|
|
465
|
+
if getattr(args, "auto_promote", False):
|
|
466
|
+
try:
|
|
467
|
+
cmd_promote(argparse.Namespace(run=run.name, force=False))
|
|
468
|
+
except SystemExit as refused: # the gate's own re-check said no
|
|
469
|
+
print(f"not promoted: {refused}")
|
|
470
|
+
return
|
|
471
|
+
from voiceio import service
|
|
472
|
+
if service.is_installed() and not service.restart_when_idle():
|
|
473
|
+
feedback.notify("VoiceIO: a better model is ready",
|
|
474
|
+
f"Switched to {run.name}; restart voiceio to use it "
|
|
475
|
+
"(a dictation was in progress). Undo: voiceio learn rollback",
|
|
476
|
+
urgent=True)
|
|
477
|
+
return
|
|
478
|
+
feedback.notify("VoiceIO: switched to a better model",
|
|
479
|
+
f"Trained on your dictation: {run.name}. Undo: voiceio learn rollback",
|
|
480
|
+
urgent=True)
|
|
481
|
+
else:
|
|
459
482
|
feedback.notify("VoiceIO: a better model is ready",
|
|
460
483
|
f"Trained on your dictation. Use it: voiceio learn promote {run.name}",
|
|
461
484
|
urgent=True)
|
|
462
485
|
|
|
463
486
|
|
|
487
|
+
def _schedule_state() -> str:
|
|
488
|
+
from voiceio import service
|
|
489
|
+
if not service.learn_timer_enabled():
|
|
490
|
+
return "off (voiceio learn schedule on)"
|
|
491
|
+
return "on (auto-promote)" if service.learn_timer_auto_promotes() else "on"
|
|
492
|
+
|
|
493
|
+
|
|
464
494
|
def cmd_schedule(args: argparse.Namespace) -> None:
|
|
465
495
|
from voiceio import config, service
|
|
466
496
|
if args.state == "on":
|
|
@@ -471,16 +501,18 @@ def cmd_schedule(args: argparse.Namespace) -> None:
|
|
|
471
501
|
if missing := train.missing_train_deps():
|
|
472
502
|
print(f"note: until {', '.join(missing)} are installed it only labels new clips;\n"
|
|
473
503
|
f" to also retrain: {train.TRAIN_HINT}")
|
|
474
|
-
if not service.install_learn_timer():
|
|
504
|
+
if not service.install_learn_timer(auto_promote=args.auto_promote):
|
|
475
505
|
raise SystemExit("could not enable the timer (needs systemd --user)")
|
|
476
506
|
print("on: every night ~03:30, `voiceio learn maintain` labels new dictation at idle "
|
|
477
|
-
"priority, retrains once 50+ clips are new, and
|
|
478
|
-
"
|
|
507
|
+
"priority, retrains once 50+ clips are new, and "
|
|
508
|
+
+ ("switches to the result when it wins on your held-out clips "
|
|
509
|
+
"(undo: voiceio learn rollback)." if args.auto_promote else
|
|
510
|
+
"tells you if the result is better. It never switches models by itself."))
|
|
479
511
|
elif args.state == "off":
|
|
480
512
|
service.uninstall_learn_timer()
|
|
481
513
|
print("off")
|
|
482
514
|
else:
|
|
483
|
-
print(
|
|
515
|
+
print(_schedule_state())
|
|
484
516
|
|
|
485
517
|
|
|
486
518
|
def _scores(result) -> dict:
|
|
@@ -506,16 +538,16 @@ def _check_beats_current(ct2: Path) -> None:
|
|
|
506
538
|
"""Refuse a promotion the evals don't support (a fine-tune can regress)."""
|
|
507
539
|
from voiceio.learn import evaluate
|
|
508
540
|
ds = store.latest_dataset()
|
|
509
|
-
current = _cfg().model.name
|
|
541
|
+
current, beam = _cfg().model.name, _cfg().model.beam_size
|
|
510
542
|
if ds is None:
|
|
511
543
|
raise SystemExit("no dataset to compare on — `voiceio learn build`, or --force")
|
|
512
|
-
missing = [m for m in (current, str(ct2)) if evaluate.latest(m, ds.name) is None]
|
|
544
|
+
missing = [m for m in (current, str(ct2)) if evaluate.latest(m, ds.name, beam=beam) is None]
|
|
513
545
|
if missing:
|
|
514
546
|
raise SystemExit("evaluate both models on the same clips first:\n"
|
|
515
547
|
+ "".join(f" voiceio learn eval --model {m}\n" for m in missing)
|
|
516
548
|
+ "(or promote anyway with --force)")
|
|
517
|
-
ok, table = evaluate.promotion_verdict(evaluate.latest(str(ct2), ds.name),
|
|
518
|
-
evaluate.latest(current, ds.name))
|
|
549
|
+
ok, table = evaluate.promotion_verdict(evaluate.latest(str(ct2), ds.name, beam=beam),
|
|
550
|
+
evaluate.latest(current, ds.name, beam=beam))
|
|
519
551
|
print(f"on {ds.name} test clips, {current} → fine-tune:\n{table}")
|
|
520
552
|
if not ok:
|
|
521
553
|
raise SystemExit("the fine-tune is worse on something — not promoting (--force overrides)")
|
|
@@ -85,23 +85,35 @@ def _cmd_service(args: argparse.Namespace) -> None:
|
|
|
85
85
|
pass
|
|
86
86
|
|
|
87
87
|
|
|
88
|
-
def wait_until_idle(timeout: float = 300.0, poll: float = 0.25
|
|
88
|
+
def wait_until_idle(timeout: float = 300.0, poll: float = 0.25,
|
|
89
|
+
finalizing_timeout: float = 60.0) -> bool:
|
|
89
90
|
"""Wait for the daemon to finish any dictation in progress (stopping it
|
|
90
|
-
mid-recording loses what was said). True once idle or not running
|
|
91
|
+
mid-recording loses what was said). True once idle or not running; False
|
|
92
|
+
when it stayed busy. A decode stuck finalizing gets a shorter wait: a
|
|
93
|
+
restart is how that is recovered."""
|
|
91
94
|
import time
|
|
92
95
|
|
|
93
|
-
from voiceio.config import STATE_PATH
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
96
|
+
from voiceio.config import PID_PATH, STATE_PATH
|
|
97
|
+
from voiceio.pidlock import daemon_alive
|
|
98
|
+
start, told = time.monotonic(), False
|
|
99
|
+
try:
|
|
100
|
+
while True:
|
|
101
|
+
if not daemon_alive(PID_PATH):
|
|
102
|
+
return True # crashed or stopped: its state file is stale
|
|
103
|
+
try:
|
|
104
|
+
state = STATE_PATH.read_text().strip()
|
|
105
|
+
except OSError:
|
|
106
|
+
return True # pre-1.1 daemon: nothing to wait for
|
|
107
|
+
if state in ("idle", "error"):
|
|
108
|
+
return True
|
|
109
|
+
waited = time.monotonic() - start
|
|
110
|
+
if waited > (finalizing_timeout if state == "finalizing" else timeout):
|
|
111
|
+
print(f"Still {state}; going ahead. (--now skips the wait.)", file=sys.stderr)
|
|
112
|
+
return False
|
|
113
|
+
if not told:
|
|
114
|
+
print(f"Waiting for the dictation in progress to finish ({state})…", flush=True)
|
|
115
|
+
told = True
|
|
116
|
+
time.sleep(poll)
|
|
117
|
+
except KeyboardInterrupt:
|
|
118
|
+
print("\nNot waiting.", file=sys.stderr)
|
|
119
|
+
sys.exit(130)
|
|
@@ -247,7 +247,8 @@ def _learning_status(cfg) -> dict[str, str]:
|
|
|
247
247
|
out["Recordings"] = f"on ({n} kept to learn from)"
|
|
248
248
|
else:
|
|
249
249
|
out["Recordings"] = "off (nothing to learn from)"
|
|
250
|
-
out["Nightly learning"] = "
|
|
250
|
+
out["Nightly learning"] = ("off" if not service.learn_timer_enabled() else
|
|
251
|
+
"on (auto-promote)" if service.learn_timer_auto_promotes() else "on")
|
|
251
252
|
return out
|
|
252
253
|
|
|
253
254
|
|
|
@@ -156,11 +156,11 @@ def save(result: EvalResult) -> Path:
|
|
|
156
156
|
return path
|
|
157
157
|
|
|
158
158
|
|
|
159
|
-
def latest(model: str, dataset: str, mode: str = "offline") -> dict | None:
|
|
160
|
-
"""The newest saved run of `model` (a faster-whisper name or path)
|
|
161
|
-
`dataset` scored by the current metrics, or None."""
|
|
159
|
+
def latest(model: str, dataset: str, mode: str = "offline", beam: int = 5) -> dict | None:
|
|
160
|
+
"""The newest saved run of `model` (a faster-whisper name or path) at
|
|
161
|
+
beam width `beam` on `dataset` scored by the current metrics, or None."""
|
|
162
162
|
runs = learn_dir() / "runs"
|
|
163
|
-
want = f"faster-whisper:{model}"
|
|
163
|
+
want = f"faster-whisper:{model}" + (f"@beam{beam}" if beam != 5 else "")
|
|
164
164
|
for path in sorted(runs.glob("eval-*.json"), reverse=True) if runs.exists() else []:
|
|
165
165
|
try:
|
|
166
166
|
data = json.loads(path.read_text())
|
|
@@ -173,6 +173,9 @@ def latest(model: str, dataset: str, mode: str = "offline") -> dict | None:
|
|
|
173
173
|
return None
|
|
174
174
|
|
|
175
175
|
|
|
176
|
+
# Fewer held-out clips than this can't tell a better model from luck.
|
|
177
|
+
MIN_EVAL_CLIPS = 5
|
|
178
|
+
|
|
176
179
|
# A fine-tune may trade a hair of WER for vocabulary; it may not regress either.
|
|
177
180
|
WER_SLACK = 0.002
|
|
178
181
|
|
|
@@ -196,7 +199,10 @@ def paired(candidate: dict, current: dict, *, n_boot: int = 2000) -> dict | None
|
|
|
196
199
|
|
|
197
200
|
|
|
198
201
|
def promotion_verdict(candidate: dict, current: dict) -> tuple[bool, str]:
|
|
199
|
-
"""Is `candidate` at least as good as `current` on the same clips?
|
|
202
|
+
"""Is `candidate` at least as good as `current` on the same clips?
|
|
203
|
+
|
|
204
|
+
No verdict without evidence: too few clips, different clips, or a clip
|
|
205
|
+
the candidate failed to decode at all (a crashed decode scores 0% WER)."""
|
|
200
206
|
rows = [("WER", current["wer"], candidate["wer"], "{:.1%}"),
|
|
201
207
|
("vocab recall", current["term_recall"], candidate["term_recall"], "{:.1%}"),
|
|
202
208
|
("loops", current["repetitions"], candidate["repetitions"], "{}")]
|
|
@@ -208,6 +214,10 @@ def promotion_verdict(candidate: dict, current: dict) -> tuple[bool, str]:
|
|
|
208
214
|
f"WER change {p['mean']:+.1%} (90% interval {p['lo']:+.1%} … {p['hi']:+.1%})")
|
|
209
215
|
if p["lo"] < 0 < p["hi"]:
|
|
210
216
|
table += "\n (the interval spans zero: the WER difference may be luck)"
|
|
217
|
+
clips = candidate.get("clips", 0)
|
|
218
|
+
if candidate.get("failures", 0) or clips != current.get("clips", 0) or clips < MIN_EVAL_CLIPS:
|
|
219
|
+
return False, table + (f"\n not enough evidence: {clips} clips "
|
|
220
|
+
f"({candidate.get('failures', 0)} failed) vs {current.get('clips', 0)}")
|
|
211
221
|
ok = (candidate["wer"] <= current["wer"] + WER_SLACK
|
|
212
222
|
and candidate["term_recall"] >= current["term_recall"]
|
|
213
223
|
and candidate["repetitions"] <= current["repetitions"])
|
|
@@ -26,8 +26,10 @@ class Item:
|
|
|
26
26
|
reason: str
|
|
27
27
|
|
|
28
28
|
|
|
29
|
-
def _contains(seg_words: list[str], live_words: list[str]
|
|
30
|
-
|
|
29
|
+
def _contains(seg_words: list[str], live_words: list[str],
|
|
30
|
+
tolerance: float = _DISAGREE_WER) -> bool:
|
|
31
|
+
"""Whether the segment's words occur (nearly, within `tolerance` of its
|
|
32
|
+
words) contiguously in the live text."""
|
|
31
33
|
n = len(seg_words)
|
|
32
34
|
if not n:
|
|
33
35
|
return True
|
|
@@ -36,7 +38,7 @@ def _contains(seg_words: list[str], live_words: list[str]) -> bool:
|
|
|
36
38
|
window = live_words[i:i + n]
|
|
37
39
|
match = sum(a == b for a, b in zip(seg_words, window)) / n
|
|
38
40
|
best = max(best, match)
|
|
39
|
-
if best >= 1 -
|
|
41
|
+
if best >= 1 - tolerance:
|
|
40
42
|
return True
|
|
41
43
|
return False
|
|
42
44
|
|
|
@@ -84,12 +86,31 @@ def live_span(segment_text: str, live: str) -> str | None:
|
|
|
84
86
|
|
|
85
87
|
|
|
86
88
|
def queue_all(clips: list[Clip], labels: dict[str, Label]) -> list[Item]:
|
|
87
|
-
"""
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
89
|
+
"""Segments to check for a gold test set, cheapest clips first.
|
|
90
|
+
|
|
91
|
+
A reference is only trustworthy if all of it was checked — but where the
|
|
92
|
+
teacher and the live decode agree word for word, two independent models
|
|
93
|
+
already did; those segments are kept as they are, and only the rest are
|
|
94
|
+
asked. Clips needing the fewest answers come first, so each minute of
|
|
95
|
+
review completes as many gold clips as possible. A clip with no live
|
|
96
|
+
decode (an import) has every segment asked; one where everything agrees
|
|
97
|
+
asks nothing and stays a teacher label.
|
|
98
|
+
"""
|
|
99
|
+
per_clip: list[list[Item]] = []
|
|
100
|
+
for clip in clips:
|
|
101
|
+
lab = labels.get(clip.audio)
|
|
102
|
+
if lab is None or lab.reviewed:
|
|
103
|
+
continue
|
|
104
|
+
live = metrics.words(clip.raw)
|
|
105
|
+
items = [Item(clip, lab, i, "test set: teacher and live disagree" if live else "test set")
|
|
106
|
+
for i, seg in enumerate(lab.segments)
|
|
107
|
+
# Word for word: a gold reference can't skip "kalchi" vs
|
|
108
|
+
# "Kalshi" in an otherwise matching sentence.
|
|
109
|
+
if not live or not _contains(metrics.words(seg.text), live, tolerance=0.0)]
|
|
110
|
+
if items:
|
|
111
|
+
per_clip.append(items)
|
|
112
|
+
per_clip.sort(key=len)
|
|
113
|
+
return [it for items in per_clip for it in items]
|
|
93
114
|
|
|
94
115
|
|
|
95
116
|
def run(items: list[Item], *, ask: Callable[[Item], str | None],
|