vocalize-cli 0.10.0__tar.gz → 0.10.1__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.
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/CHANGELOG.md +36 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/PKG-INFO +3 -2
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/README.md +2 -1
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/dictation.md +4 -1
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-6-release-0-10-0/report.md +1 -0
- vocalize_cli-0.10.1/docs/roadmap.md +24 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/claude_stop_hook.py +38 -3
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_claude_stop_hook.py +61 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_dictate.py +71 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_kokoro_provider.py +2 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_recorder_build.py +36 -0
- vocalize_cli-0.10.1/tests/test_uv_path.py +62 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/__init__.py +1 -1
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/dictate.py +48 -5
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/__init__.py +10 -2
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/install.py +14 -4
- vocalize_cli-0.10.1/vocalize/recorder/Recorder.entitlements +8 -0
- vocalize_cli-0.10.0/docs/roadmap.md +0 -22
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/.env.example +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/.github/workflows/ci.yml +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/.gitignore +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/LICENSE +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/installation.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/next-features-analysis.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/choreography.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/decisions.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/design.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/review-0.10.0.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/report.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-10-release-0-11-0/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-10-release-0-11-0/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-2-stt-runtime/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-2-stt-runtime/report.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-2-stt-runtime/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-3-recorder/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-3-recorder/report.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-3-recorder/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-4-dictation/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-4-dictation/report.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-4-dictation/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/report.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-6-release-0-10-0/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-6-release-0-10-0/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-7-portal-read/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-7-portal-read/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-8-portal-write/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-8-portal-write/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-9-portal-page/project-plan.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-9-portal-page/validate-exit.sh +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/spike-2026-09-01.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/split-assessment.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/verification.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/provider-credentials.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-config-portal-design.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-dictation-design.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-voicebox-findings.md +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/install_hook.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/install_quick_action.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Dictate with Vocalize.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Dictate with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak Latest Plan.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Speak with Vocalize.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Info.plist +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/quick_actions/Stop Vocalize.workflow/Contents/Resources/document.wflow +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/speak_options.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/hooks/speak_url_gate.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/pyproject.toml +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/conftest.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_audio.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_auth.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_cache.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_chain.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_cli.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_clipboard.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_config.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_elevenlabs_provider.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_exceptions.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_google_provider.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_http.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_install_hook.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_install_quick_action.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_kokoro_manifest.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_kokoro_worker.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_ledger.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_listen_check.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_local_install.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_openai_provider.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_polly_provider.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_preprocess.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_providers_registry.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_readiness.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_say_provider.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_speak_options.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_speak_url_gate.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_tts.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_whisper_manifest.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_whisper_worker.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/tests/test_wizard.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/__main__.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/audio.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/auth.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/cache.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/chain.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/cli.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/clipboard.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/config.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/exceptions.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/interrupted.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/ledger.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/kokoro_manifest.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/kokoro_worker.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/whisper_manifest.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/local/whisper_worker.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/preprocess.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/__init__.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/_http.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/elevenlabs.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/google.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/kokoro.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/openai.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/polly.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/providers/say.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/readiness.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/recorder/Info.plist.in +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/recorder/VocalizeRecorder.swift +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/tts.py +0 -0
- {vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/vocalize/wizard.py +0 -0
|
@@ -3,6 +3,42 @@
|
|
|
3
3
|
All notable changes to this project are documented here. Format follows
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
5
5
|
|
|
6
|
+
## 0.10.1 - 2026-09-02
|
|
7
|
+
|
|
8
|
+
Three fixes found in the first owner-present run of 0.10.0's dictation.
|
|
9
|
+
Together they meant no hotkey dictation could succeed on 0.10.0; upgrade.
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- **No dictation could ever start on a fresh install.** The recorder was
|
|
14
|
+
signed with the hardened runtime but without the
|
|
15
|
+
`com.apple.security.device.audio-input` entitlement, so macOS refused the
|
|
16
|
+
microphone on the spot — no permission dialog, status stuck at
|
|
17
|
+
`notDetermined` — and every first press ended in "The recorder did not
|
|
18
|
+
start". The bundle is now signed with
|
|
19
|
+
`vocalize/recorder/Recorder.entitlements`, and the entitlements are part
|
|
20
|
+
of the recorder's fingerprint, so `vocalize local install --stt` rebuilds
|
|
21
|
+
the bundle once (and, as with any rebuild, macOS asks for the microphone
|
|
22
|
+
again — it never actually asked before).
|
|
23
|
+
- **Every hotkey dictation ended in "Dictation failed" on a machine whose
|
|
24
|
+
`uv` came from Homebrew.** A Services environment has a bare PATH, and
|
|
25
|
+
`uv_path()` looked only there and in `~/.local/bin`; the same dictation
|
|
26
|
+
worked from a terminal. `/opt/homebrew/bin/uv` and `/usr/local/bin/uv`
|
|
27
|
+
are now tried too (this also covers Kokoro from a Quick Action).
|
|
28
|
+
- **Holding the dictation hotkey down turned into a cancel-and-restart
|
|
29
|
+
loop.** macOS re-fires a Service shortcut at the key-repeat rate, and
|
|
30
|
+
every repeat landed as a second press. Presses within half a second of
|
|
31
|
+
the previous one are now ignored as the same press; a deliberate cancel
|
|
32
|
+
is "press, a beat, press" inside the two-second window, as before.
|
|
33
|
+
- `hooks/claude_stop_hook.py --latest`, run from inside a Claude Code turn
|
|
34
|
+
(which is how `/speak` runs it), spoke the agent's own status line —
|
|
35
|
+
"Checking settings." — instead of the response the user asked to hear.
|
|
36
|
+
It now skips the turn in progress, back past the `/speak` message
|
|
37
|
+
itself, and speaks the response before it. From a plain terminal, where
|
|
38
|
+
no turn is in progress, `--latest` still speaks the newest response; the
|
|
39
|
+
hook tells the two apart by the `CLAUDECODE` variable Claude Code sets
|
|
40
|
+
in its shell. The Stop-hook path is unchanged.
|
|
41
|
+
|
|
6
42
|
## 0.10.0 - 2026-09-02
|
|
7
43
|
|
|
8
44
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: vocalize-cli
|
|
3
|
-
Version: 0.10.
|
|
3
|
+
Version: 0.10.1
|
|
4
4
|
Summary: A CLI that turns text, markdown, or piped stdin into speech via the ElevenLabs API, with markdown-table-aware preprocessing.
|
|
5
5
|
Project-URL: Homepage, https://github.com/matthager12-collab/vocalize
|
|
6
6
|
Project-URL: Repository, https://github.com/matthager12-collab/vocalize
|
|
@@ -478,7 +478,8 @@ terminal, if you'd rather trigger it that way.
|
|
|
478
478
|
transcript is on your clipboard; nothing is typed for you automatically.
|
|
479
479
|
- **Nothing heard** (silence, or a microphone that isn't actually picking
|
|
480
480
|
anything up) ends the dictation quietly — no clipboard write.
|
|
481
|
-
- **Cancel** with a second press *within two seconds* of the first
|
|
481
|
+
- **Cancel** with a second press *within two seconds* of the first (but
|
|
482
|
+
not within half a second — that's a held key, and it's ignored), or at
|
|
482
483
|
any point with `vocalize listen --cancel` — the audio is discarded, never
|
|
483
484
|
transcribed.
|
|
484
485
|
- **A third press while transcribing is refused**: a Pop, and "Still
|
|
@@ -442,7 +442,8 @@ terminal, if you'd rather trigger it that way.
|
|
|
442
442
|
transcript is on your clipboard; nothing is typed for you automatically.
|
|
443
443
|
- **Nothing heard** (silence, or a microphone that isn't actually picking
|
|
444
444
|
anything up) ends the dictation quietly — no clipboard write.
|
|
445
|
-
- **Cancel** with a second press *within two seconds* of the first
|
|
445
|
+
- **Cancel** with a second press *within two seconds* of the first (but
|
|
446
|
+
not within half a second — that's a held key, and it's ignored), or at
|
|
446
447
|
any point with `vocalize listen --cancel` — the audio is discarded, never
|
|
447
448
|
transcribed.
|
|
448
449
|
- **A third press while transcribing is refused**: a Pop, and "Still
|
|
@@ -132,7 +132,10 @@ touching System Settings at all.
|
|
|
132
132
|
|
|
133
133
|
**A second press within two seconds of the first is a cancel**, not a
|
|
134
134
|
stop — treated as "I changed my mind" rather than the end of a very short
|
|
135
|
-
sentence.
|
|
135
|
+
sentence. (Presses closer than half a second apart are the key being
|
|
136
|
+
*held* — macOS repeats a Service shortcut at the key-repeat rate — and are
|
|
137
|
+
ignored, so a cancel is "press, a beat, press".) So is
|
|
138
|
+
`vocalize listen --cancel`, from a terminal, at any point
|
|
136
139
|
— including while a take is being transcribed. (The transcription that was
|
|
137
140
|
already running keeps its own copy of the recording and finishes normally;
|
|
138
141
|
`--cancel` only releases the hotkey so your next press starts a fresh
|
|
@@ -11,6 +11,7 @@ Branch `next-features`, shared checkout. Source: [project-plan.md](./project-pla
|
|
|
11
11
|
- `status` (no mic): [run-1-status/report.md](../run-1-status/report.md) (the module and its exit-code contract) plus [run-4-dictation/report.md](../run-4-dictation/report.md) T-45 (the four STT readiness rows), further hardened this run (mic-verdict age, locked probe registry — see review-0.10.0.md).
|
|
12
12
|
- Resume drill (no mic): [run-5-resume/report.md](../run-5-resume/report.md) T-46/T-47, with this run's review closing the gaps it left open (DEC-013's queued-read case, DEC-014f's voice/speed/chunking carry-over).
|
|
13
13
|
- Note: rebuilding the recorder for the Swift permission-dialog fix (`94e5a46`) reset the microphone grant on the reference Mac to `notDetermined`, so Manual check 1 starts from a genuinely fresh state — not a regression, the state the fix exists for.
|
|
14
|
+
- **Owner-present run, 2026-09-02 evening (after 0.10.0 published):** Manual check 1 (mic grant) and 2 (hotkey path, real voice, transcript on the clipboard) done — but only after three defects were fixed and released as 0.10.1: the recorder was signed with the hardened runtime and no `audio-input` entitlement (no prompt ever appeared); `uv_path()` could not find Homebrew's uv from a Service's bare PATH (every stop ended in "Dictation failed"); and a held hotkey re-fired as serial cancel/restart presses (now debounced). Manual 3 is partly covered (held-key refusal); 4, 4b, 4c and 5 remain.
|
|
14
15
|
- **T-53: pending** — merge and publish. Not started. This run does not merge to main or publish; per project-plan.md that happens on the owner's word, after T-52. The orchestrator handles this after reading this report.
|
|
15
16
|
|
|
16
17
|
## Security-gate summary (every run's verdict)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# Roadmap
|
|
2
|
+
|
|
3
|
+
Future work, with the research behind each item. Status legend: **planned** = analysed, decisions pending, nothing built; **spiked** = feasibility measured; **building**; **shipped**.
|
|
4
|
+
|
|
5
|
+
| Item | Status | Tracking | Research |
|
|
6
|
+
|---|---|---|---|
|
|
7
|
+
| Hotkey-triggered local dictation (voice → text via a local Whisper model, Quick Action toggle, clipboard output, optional cleanup) | **shipped** in 0.10.0, usable from 0.10.1 | [#1](https://github.com/matthager12-collab/vocalize/issues/1) · [plan](plans/2026-09-next-features/plan.md) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-dictation-design.md) · [voicebox findings](research/2026-09-01-voicebox-findings.md) |
|
|
8
|
+
| `vocalize status` one-screen readiness check | **shipped** in 0.10.0 | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) |
|
|
9
|
+
| Spoken cues for dictation (`[stt] cues = "words"`: "start" / "stopped" / "ready" instead of, or as well as, the sounds) | building | — | owner request, 2026-09-02 |
|
|
10
|
+
| Config portal (`vocalize portal`, stdlib local web page on top of the same readiness rows) | planned — runs 7–10 of the [plan](plans/2026-09-next-features/plan.md) | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-config-portal-design.md) |
|
|
11
|
+
|
|
12
|
+
## Decisions (2026-09-01, closed 2026-09-02)
|
|
13
|
+
|
|
14
|
+
1. The dictation spike ran ([results](plans/2026-09-next-features/spike-2026-09-01.md)); Whisper `small.en` is the default.
|
|
15
|
+
2. `vocalize status` first (0.10.0); the portal follows as 0.11.0.
|
|
16
|
+
3. Hotkey chord ⌃⌥⌘D, set by the user in System Settings.
|
|
17
|
+
|
|
18
|
+
## Deferred on purpose
|
|
19
|
+
|
|
20
|
+
Hold-to-talk (needs an event tap + Input Monitoring, ~6 h), auto-paste (Accessibility, ~2 h, belongs in the recorder bundle), Apple STT mode (~1.5 h), menu-bar agent (~12 h), local-LLM cleanup, resident/pre-warmed STT worker (only if the spike shows >2.5 s overhead), a native SwiftUI/Tauri app.
|
|
21
|
+
|
|
22
|
+
## Sequencing suggestion
|
|
23
|
+
|
|
24
|
+
Dictation first (the spike decides the engine, then ~20 h), portal or `status` second.
|
|
@@ -7,7 +7,10 @@
|
|
|
7
7
|
2. On demand, with `--latest`. Nothing is read from stdin; the script finds
|
|
8
8
|
the most recently written transcript under ~/.claude/projects and speaks
|
|
9
9
|
that response. Run it when you want speech instead of installing the
|
|
10
|
-
hook and getting it after every turn.
|
|
10
|
+
hook and getting it after every turn. Run from inside a Claude Code turn
|
|
11
|
+
(the /speak command does this; Claude Code sets CLAUDECODE=1 in its
|
|
12
|
+
shell) it skips that turn — the newest text there is the agent's own
|
|
13
|
+
status line — and speaks the response before it.
|
|
11
14
|
|
|
12
15
|
Add `--print-length` to either mode to print the response's character
|
|
13
16
|
count instead of speaking — for wrappers deciding whether to ask about
|
|
@@ -59,7 +62,27 @@ def _speech_timeout(text: str) -> int:
|
|
|
59
62
|
return min(TIMEOUT_CEILING_SECONDS, TIMEOUT_BASE_SECONDS + len(text) // CHARS_PER_SECOND)
|
|
60
63
|
|
|
61
64
|
|
|
62
|
-
def
|
|
65
|
+
def _is_human_message(entry: dict) -> bool:
|
|
66
|
+
"""A user entry the person typed, not one Claude Code wrote.
|
|
67
|
+
|
|
68
|
+
Claude Code logs tool results and its own injected text — a skill's
|
|
69
|
+
body, say — as user entries too; the latter carry ``isMeta``.
|
|
70
|
+
"""
|
|
71
|
+
if entry.get("type") != "user" or entry.get("isMeta"):
|
|
72
|
+
return False
|
|
73
|
+
content = (entry.get("message") or {}).get("content") or []
|
|
74
|
+
if isinstance(content, str):
|
|
75
|
+
return True
|
|
76
|
+
return any(isinstance(b, dict) and b.get("type") != "tool_result" for b in content)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _extract_last_assistant_text(transcript_path: str, skip_current_turn: bool = False) -> str:
|
|
80
|
+
"""Newest assistant text in the transcript.
|
|
81
|
+
|
|
82
|
+
With skip_current_turn, everything after the newest human message is
|
|
83
|
+
the turn still in progress (the one running this script); skip it and
|
|
84
|
+
return the response before that message instead.
|
|
85
|
+
"""
|
|
63
86
|
last_text_parts: list[str] = []
|
|
64
87
|
try:
|
|
65
88
|
with open(transcript_path, "r", encoding="utf-8") as f:
|
|
@@ -76,6 +99,11 @@ def _extract_last_assistant_text(transcript_path: str) -> str:
|
|
|
76
99
|
except json.JSONDecodeError:
|
|
77
100
|
continue
|
|
78
101
|
|
|
102
|
+
if skip_current_turn:
|
|
103
|
+
if _is_human_message(entry):
|
|
104
|
+
skip_current_turn = False # past the /speak message; older is fair game
|
|
105
|
+
continue
|
|
106
|
+
|
|
79
107
|
if entry.get("type") != "assistant":
|
|
80
108
|
continue
|
|
81
109
|
|
|
@@ -117,17 +145,24 @@ def main() -> int:
|
|
|
117
145
|
if "--latest" in sys.argv[1:]:
|
|
118
146
|
# On-demand mode: no hook payload on stdin, so don't read it at all.
|
|
119
147
|
transcript_path = _find_latest_transcript()
|
|
148
|
+
# Run from inside a Claude Code turn (the /speak command does this,
|
|
149
|
+
# and Claude Code sets CLAUDECODE=1 in its shell), the newest
|
|
150
|
+
# assistant text is that turn's own status line — "Checking
|
|
151
|
+
# settings." — not the response the user asked to hear. From a plain
|
|
152
|
+
# terminal there is no turn in progress and the newest text is right.
|
|
153
|
+
skip_current_turn = bool(os.environ.get("CLAUDECODE"))
|
|
120
154
|
else:
|
|
121
155
|
try:
|
|
122
156
|
payload = json.load(sys.stdin)
|
|
123
157
|
except json.JSONDecodeError:
|
|
124
158
|
payload = {}
|
|
125
159
|
transcript_path = payload.get("transcript_path")
|
|
160
|
+
skip_current_turn = False # at Stop the turn is over: newest text is the target
|
|
126
161
|
|
|
127
162
|
if not transcript_path:
|
|
128
163
|
return 0 # nothing to do — don't block Claude Code on a hook error
|
|
129
164
|
|
|
130
|
-
text = _extract_last_assistant_text(transcript_path)
|
|
165
|
+
text = _extract_last_assistant_text(transcript_path, skip_current_turn)
|
|
131
166
|
if not text.strip():
|
|
132
167
|
if "--print-length" in sys.argv[1:]:
|
|
133
168
|
print(0)
|
|
@@ -14,6 +14,10 @@ def _assistant(content) -> str:
|
|
|
14
14
|
return json.dumps({"type": "assistant", "message": {"content": content}})
|
|
15
15
|
|
|
16
16
|
|
|
17
|
+
def _user(content, **extra) -> str:
|
|
18
|
+
return json.dumps({"type": "user", "message": {"content": content}, **extra})
|
|
19
|
+
|
|
20
|
+
|
|
17
21
|
def _text(text: str) -> dict:
|
|
18
22
|
return {"type": "text", "text": text}
|
|
19
23
|
|
|
@@ -28,6 +32,9 @@ def _patch_main(monkeypatch, payload, which="/usr/local/bin/vocalize", run=None,
|
|
|
28
32
|
"""Wire up stdin, PATH lookup and subprocess so main() can't shell out."""
|
|
29
33
|
monkeypatch.delenv("VOCALIZE_MAX_CHARS", raising=False)
|
|
30
34
|
monkeypatch.delenv("VOCALIZE_BIN", raising=False)
|
|
35
|
+
# The suite itself often runs inside a Claude Code turn, where this is
|
|
36
|
+
# set; a --latest test must not change meaning depending on who ran it.
|
|
37
|
+
monkeypatch.delenv("CLAUDECODE", raising=False)
|
|
31
38
|
monkeypatch.setattr(hook.sys, "argv", ["claude_stop_hook.py"])
|
|
32
39
|
monkeypatch.setattr(
|
|
33
40
|
hook.sys, "stdin", io.StringIO(json.dumps(payload)) if stdin is None else stdin
|
|
@@ -102,6 +109,31 @@ def test_falls_back_past_tool_use_only_entry(tmp_path):
|
|
|
102
109
|
assert hook._extract_last_assistant_text(path) == "the spoken answer"
|
|
103
110
|
|
|
104
111
|
|
|
112
|
+
def test_skip_current_turn_returns_the_response_before_the_newest_human_message(tmp_path):
|
|
113
|
+
# /speak runs the hook mid-turn. By then the agent has written a status
|
|
114
|
+
# line and Claude Code has logged the Bash call that runs the hook (and
|
|
115
|
+
# its result). The response the user wants is the one BEFORE /speak.
|
|
116
|
+
# Only the typed /speak line counts as the person speaking: tool results
|
|
117
|
+
# and Claude Code's own injected text (isMeta) are user entries too.
|
|
118
|
+
path = _write_transcript(
|
|
119
|
+
tmp_path,
|
|
120
|
+
[
|
|
121
|
+
_assistant([_text("the long response the user asked to hear")]),
|
|
122
|
+
_user("<command-name>/speak</command-name>"),
|
|
123
|
+
_user([_text("Base directory for this skill: ...")], isMeta=True),
|
|
124
|
+
_assistant([_text("Checking settings.")]),
|
|
125
|
+
_assistant([{"type": "tool_use", "name": "Bash", "input": {}}]),
|
|
126
|
+
_user([{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}]),
|
|
127
|
+
],
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
assert hook._extract_last_assistant_text(path, skip_current_turn=True) == (
|
|
131
|
+
"the long response the user asked to hear"
|
|
132
|
+
)
|
|
133
|
+
# The Stop-hook path still wants the newest text: at Stop, the turn is over.
|
|
134
|
+
assert hook._extract_last_assistant_text(path) == "Checking settings."
|
|
135
|
+
|
|
136
|
+
|
|
105
137
|
def test_ignores_malformed_json_lines(tmp_path):
|
|
106
138
|
# The garbage line must sit AFTER the newest assistant entry. The scan
|
|
107
139
|
# walks reversed(lines) and breaks on the first text it finds, so a
|
|
@@ -326,6 +358,35 @@ def test_latest_mode_speaks_newest_transcript_without_reading_stdin(monkeypatch,
|
|
|
326
358
|
assert calls[0][-1] == "newest session"
|
|
327
359
|
|
|
328
360
|
|
|
361
|
+
def test_latest_mode_skips_the_current_turn_only_inside_claude_code(monkeypatch, tmp_path):
|
|
362
|
+
transcript = tmp_path / "session.jsonl"
|
|
363
|
+
transcript.write_text(
|
|
364
|
+
"\n".join(
|
|
365
|
+
[
|
|
366
|
+
_assistant([_text("the long response")]),
|
|
367
|
+
_user("/speak"),
|
|
368
|
+
_assistant([_text("Checking settings.")]),
|
|
369
|
+
]
|
|
370
|
+
)
|
|
371
|
+
+ "\n",
|
|
372
|
+
encoding="utf-8",
|
|
373
|
+
)
|
|
374
|
+
calls = _patch_main(monkeypatch, {})
|
|
375
|
+
monkeypatch.setattr(hook.sys, "argv", ["claude_stop_hook.py", "--latest"])
|
|
376
|
+
monkeypatch.setattr(hook, "_find_latest_transcript", lambda: str(transcript))
|
|
377
|
+
|
|
378
|
+
# From a plain terminal there is no turn in progress: newest text wins,
|
|
379
|
+
# exactly as the README's on-demand mode promises.
|
|
380
|
+
assert hook.main() == 0
|
|
381
|
+
assert calls[-1][-1] == "Checking settings."
|
|
382
|
+
|
|
383
|
+
# Inside a Claude Code turn, that newest text is the agent's own status
|
|
384
|
+
# line; the user wants the response before their /speak.
|
|
385
|
+
monkeypatch.setenv("CLAUDECODE", "1")
|
|
386
|
+
assert hook.main() == 0
|
|
387
|
+
assert calls[-1][-1] == "the long response"
|
|
388
|
+
|
|
389
|
+
|
|
329
390
|
def test_subprocess_failure_is_logged_not_raised(monkeypatch, tmp_path, capsys):
|
|
330
391
|
def boom(argv):
|
|
331
392
|
raise RuntimeError("no audio device")
|
|
@@ -162,6 +162,9 @@ def harness(tmp_path, monkeypatch):
|
|
|
162
162
|
# the grace for a record no fake read is going to write. The wait has
|
|
163
163
|
# its own two tests below; these are about the toggle.
|
|
164
164
|
monkeypatch.setattr(dictate, "_RESUME_GRACE", 0.05)
|
|
165
|
+
# Presses here land microseconds apart, which to the real debounce is
|
|
166
|
+
# a held key. Its own tests below put the window back.
|
|
167
|
+
monkeypatch.setattr(dictate, "_DEBOUNCE", 0.0)
|
|
165
168
|
|
|
166
169
|
# A live PID is our recorder; a dead one is nothing. Replaced because
|
|
167
170
|
# no shebang script can ever be *named* `recorder` to real `ps`.
|
|
@@ -486,6 +489,74 @@ def test_a_second_press_within_the_window_cancels(recorder, transcriber, harness
|
|
|
486
489
|
assert any(dictate._NOTIFY_CANCELLED in line for line in harness.notifications())
|
|
487
490
|
|
|
488
491
|
|
|
492
|
+
def test_a_held_key_is_ignored_not_cancelled(recorder, transcriber, harness, monkeypatch):
|
|
493
|
+
"""macOS re-fires a Service shortcut at the key-repeat rate while the
|
|
494
|
+
chord is held. Seen live 2026-09-02: every repeat landed as a cancel and
|
|
495
|
+
then a fresh start, dozens of times a minute. Presses inside the
|
|
496
|
+
debounce are the same press."""
|
|
497
|
+
recorder()
|
|
498
|
+
transcriber()
|
|
499
|
+
monkeypatch.setattr(dictate, "_DEBOUNCE", 0.5)
|
|
500
|
+
assert start() == 0
|
|
501
|
+
|
|
502
|
+
for _ in range(3): # the key is still down
|
|
503
|
+
assert dictate.toggle(stt()) == 0
|
|
504
|
+
|
|
505
|
+
assert dictate.session_path().is_file() # still recording
|
|
506
|
+
assert harness.played == ["Tink.aiff"] # no Pop, no cancel
|
|
507
|
+
assert harness.notifications() == []
|
|
508
|
+
assert lines(harness.uv_argv) == []
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def test_a_second_tap_after_the_debounce_still_cancels(recorder, transcriber, harness, monkeypatch):
|
|
512
|
+
recorder()
|
|
513
|
+
transcriber()
|
|
514
|
+
monkeypatch.setattr(dictate, "_DEBOUNCE", 0.05)
|
|
515
|
+
start()
|
|
516
|
+
time.sleep(0.06)
|
|
517
|
+
|
|
518
|
+
assert dictate.toggle(stt()) == 0 # inside the two-second cancel window
|
|
519
|
+
|
|
520
|
+
assert not dictate.session_path().exists()
|
|
521
|
+
assert any(dictate._NOTIFY_CANCELLED in line for line in harness.notifications())
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def test_a_repeat_queued_behind_a_slow_press_is_still_ignored(recorder, harness, monkeypatch):
|
|
525
|
+
"""The Services runner runs presses one after another: a repeat fired
|
|
526
|
+
while a press was still launching the recorder begins the instant that
|
|
527
|
+
press returns. Measured from the press's *start*, it would look like a
|
|
528
|
+
deliberate second tap; the exit stamp is what catches it."""
|
|
529
|
+
recorder()
|
|
530
|
+
monkeypatch.setattr(dictate, "_DEBOUNCE", 0.1)
|
|
531
|
+
launch = dictate._launch_recorder
|
|
532
|
+
|
|
533
|
+
def slow_launch(workdir, stt):
|
|
534
|
+
time.sleep(0.15) # longer than the whole debounce window
|
|
535
|
+
return launch(workdir, stt)
|
|
536
|
+
|
|
537
|
+
monkeypatch.setattr(dictate, "_launch_recorder", slow_launch)
|
|
538
|
+
assert start() == 0
|
|
539
|
+
|
|
540
|
+
assert dictate.toggle(stt()) == 0 # the queued repeat
|
|
541
|
+
|
|
542
|
+
assert dictate.session_path().is_file()
|
|
543
|
+
assert harness.played == ["Tink.aiff"]
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def test_the_press_stamp_is_refreshed_by_ignored_presses(recorder, harness, monkeypatch):
|
|
547
|
+
"""Each repeat re-arms the window, so a key held longer than the window
|
|
548
|
+
stays ignored until it is released."""
|
|
549
|
+
recorder()
|
|
550
|
+
monkeypatch.setattr(dictate, "_DEBOUNCE", 0.1)
|
|
551
|
+
start()
|
|
552
|
+
for _ in range(4):
|
|
553
|
+
time.sleep(0.05) # each gap is inside the window; the total is not
|
|
554
|
+
assert dictate.toggle(stt()) == 0
|
|
555
|
+
|
|
556
|
+
assert dictate.session_path().is_file()
|
|
557
|
+
assert harness.played == ["Tink.aiff"]
|
|
558
|
+
|
|
559
|
+
|
|
489
560
|
def test_cancel_from_the_command_line_discards_the_recording(
|
|
490
561
|
recorder, transcriber, harness
|
|
491
562
|
):
|
|
@@ -177,6 +177,8 @@ def no_uv(monkeypatch, tmp_path):
|
|
|
177
177
|
monkeypatch.setattr(local.shutil, "which", lambda name: None)
|
|
178
178
|
# Path.home() reads $HOME, so this empties the ~/.local/bin fallback too.
|
|
179
179
|
monkeypatch.setenv("HOME", str(tmp_path / "empty-home"))
|
|
180
|
+
# ...and the Homebrew spots are real paths on a developer's machine.
|
|
181
|
+
monkeypatch.setattr(local, "UV_FALLBACKS", ())
|
|
180
182
|
|
|
181
183
|
|
|
182
184
|
@pytest.fixture
|
|
@@ -234,6 +234,42 @@ def test_the_signature_turns_the_hardened_runtime_on(bin_dir):
|
|
|
234
234
|
assert fake.calls[-1][0][fake.calls[-1][0].index("--options") + 1] == "runtime"
|
|
235
235
|
|
|
236
236
|
|
|
237
|
+
def test_the_hardened_runtime_is_signed_with_the_microphone_entitlement(bin_dir):
|
|
238
|
+
"""Under the hardened runtime, AVCaptureDevice.requestAccess is refused
|
|
239
|
+
on the spot — no dialog, status stays notDetermined — unless the binary
|
|
240
|
+
declares com.apple.security.device.audio-input. Seen live on 2026-09-02:
|
|
241
|
+
every first dictation ended in "The recorder did not start"."""
|
|
242
|
+
import plistlib
|
|
243
|
+
|
|
244
|
+
fake = FakeToolchain()
|
|
245
|
+
|
|
246
|
+
install_module.build_recorder(bin_dir=bin_dir, runner=fake)
|
|
247
|
+
|
|
248
|
+
argv = fake.calls[-1][0]
|
|
249
|
+
assert "--entitlements" in argv
|
|
250
|
+
entitlements = Path(argv[argv.index("--entitlements") + 1])
|
|
251
|
+
assert entitlements == install_module.RECORDER_ENTITLEMENTS
|
|
252
|
+
with entitlements.open("rb") as f:
|
|
253
|
+
declared = plistlib.load(f)
|
|
254
|
+
assert declared == {"com.apple.security.device.audio-input": True}
|
|
255
|
+
|
|
256
|
+
|
|
257
|
+
def test_a_changed_entitlements_file_forces_a_rebuild(bin_dir, tmp_path, monkeypatch):
|
|
258
|
+
"""The entitlements are part of the signature, so they are part of the
|
|
259
|
+
recorder's identity: change them and the bundle must be rebuilt (and
|
|
260
|
+
the user told to re-grant), exactly like a source change."""
|
|
261
|
+
copy = tmp_path / "Recorder.entitlements"
|
|
262
|
+
shutil.copy(install_module.RECORDER_ENTITLEMENTS, copy)
|
|
263
|
+
monkeypatch.setattr(install_module, "RECORDER_ENTITLEMENTS", copy)
|
|
264
|
+
|
|
265
|
+
assert install_module.build_recorder(bin_dir=bin_dir, runner=FakeToolchain())[0] == "built"
|
|
266
|
+
assert install_module.build_recorder(bin_dir=bin_dir, runner=FakeToolchain())[0] == "current"
|
|
267
|
+
|
|
268
|
+
copy.write_text(copy.read_text(encoding="utf-8") + "\n", encoding="utf-8")
|
|
269
|
+
|
|
270
|
+
assert install_module.build_recorder(bin_dir=bin_dir, runner=FakeToolchain())[0] == "rebuilt"
|
|
271
|
+
|
|
272
|
+
|
|
237
273
|
def test_the_compile_argv_is_a_list_naming_only_our_own_paths(bin_dir):
|
|
238
274
|
fake = FakeToolchain()
|
|
239
275
|
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""`vocalize.local.uv_path()`: PATH, then ~/.local/bin, then Homebrew.
|
|
2
|
+
|
|
3
|
+
The Quick Actions run under a Services environment whose PATH is bare, so
|
|
4
|
+
a Homebrew uv (Apple silicon or Intel) has to be found by name — seen live
|
|
5
|
+
on 2026-09-02, when every hotkey dictation ended in "uv is not installed"
|
|
6
|
+
while the same command worked from a terminal.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
import shutil
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import pytest
|
|
13
|
+
|
|
14
|
+
from vocalize import local
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
@pytest.fixture
|
|
18
|
+
def off_path(monkeypatch, tmp_path):
|
|
19
|
+
"""Nothing on PATH and an empty home, so only the fallbacks can answer."""
|
|
20
|
+
monkeypatch.setattr(shutil, "which", lambda name: None)
|
|
21
|
+
monkeypatch.setenv("HOME", str(tmp_path / "home"))
|
|
22
|
+
return tmp_path
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_path_wins(monkeypatch, tmp_path):
|
|
26
|
+
on_path = tmp_path / "uv"
|
|
27
|
+
on_path.write_text("")
|
|
28
|
+
monkeypatch.setattr(shutil, "which", lambda name: str(on_path) if name == "uv" else None)
|
|
29
|
+
|
|
30
|
+
assert local.uv_path() == str(on_path)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def test_the_installer_spot_in_home_is_tried_before_homebrew(off_path, monkeypatch):
|
|
34
|
+
own = off_path / "home" / ".local" / "bin" / "uv"
|
|
35
|
+
own.parent.mkdir(parents=True)
|
|
36
|
+
own.write_text("")
|
|
37
|
+
homebrew = off_path / "opt" / "homebrew" / "bin" / "uv"
|
|
38
|
+
homebrew.parent.mkdir(parents=True)
|
|
39
|
+
homebrew.write_text("")
|
|
40
|
+
monkeypatch.setattr(local, "UV_FALLBACKS", (homebrew,))
|
|
41
|
+
|
|
42
|
+
assert local.uv_path() == str(own)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_a_homebrew_uv_is_found_off_path(off_path, monkeypatch):
|
|
46
|
+
missing = off_path / "usr" / "local" / "bin" / "uv"
|
|
47
|
+
homebrew = off_path / "opt" / "homebrew" / "bin" / "uv"
|
|
48
|
+
homebrew.parent.mkdir(parents=True)
|
|
49
|
+
homebrew.write_text("")
|
|
50
|
+
monkeypatch.setattr(local, "UV_FALLBACKS", (homebrew, missing))
|
|
51
|
+
|
|
52
|
+
assert local.uv_path() == str(homebrew)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def test_no_uv_anywhere_is_none(off_path, monkeypatch):
|
|
56
|
+
monkeypatch.setattr(local, "UV_FALLBACKS", (off_path / "nope" / "uv",))
|
|
57
|
+
|
|
58
|
+
assert local.uv_path() is None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_the_shipped_fallbacks_are_homebrew_on_both_architectures():
|
|
62
|
+
assert local.UV_FALLBACKS == (Path("/opt/homebrew/bin/uv"), Path("/usr/local/bin/uv"))
|
|
@@ -75,6 +75,16 @@ _WORKDIR_PREFIX = "vocalize-dictate-"
|
|
|
75
75
|
# A second press this soon after the first is the user changing their mind,
|
|
76
76
|
# not the end of a sentence (design § Key flows).
|
|
77
77
|
_CANCEL_WINDOW = 2.0
|
|
78
|
+
# ...but a press this soon after the *previous press* is the key still being
|
|
79
|
+
# held: macOS re-fires a Service shortcut at its key-repeat rate, and every
|
|
80
|
+
# repeat used to land as a cancel, then a fresh start, for as long as the
|
|
81
|
+
# chord was down. The runner executes presses serially, so the gap between
|
|
82
|
+
# a press ending and the queued repeat beginning is one process start-up
|
|
83
|
+
# (~0.3 s here); this has to be comfortably longer than that and shorter
|
|
84
|
+
# than a deliberate second tap. The stamp lives outside the session so it
|
|
85
|
+
# also covers repeats arriving while a stop is being finished.
|
|
86
|
+
_DEBOUNCE = 1.0
|
|
87
|
+
_PRESS_NAME = "dictate.press"
|
|
78
88
|
|
|
79
89
|
# How long the first press waits for the recorder to say it started. Also
|
|
80
90
|
# the window in which a second press treats a missing `rec.pid` as "still
|
|
@@ -967,13 +977,46 @@ def _finish_take(workdir: Path, stt: dict) -> tuple[str | None, bool]:
|
|
|
967
977
|
# --- the toggle state machine -----------------------------------------
|
|
968
978
|
|
|
969
979
|
|
|
980
|
+
def _stamp_press() -> None:
|
|
981
|
+
"""Record the moment as the latest press. Best effort: a stamp that
|
|
982
|
+
cannot be written simply never debounces."""
|
|
983
|
+
try:
|
|
984
|
+
audio.ensure_private_dir(CACHE_DIR)
|
|
985
|
+
(CACHE_DIR / _PRESS_NAME).write_text(f"{time.time()}\n", encoding="utf-8")
|
|
986
|
+
except OSError:
|
|
987
|
+
pass
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def _is_key_repeat() -> bool:
|
|
991
|
+
"""Whether this press arrived within `_DEBOUNCE` of the previous stamp.
|
|
992
|
+
|
|
993
|
+
The Services runner executes presses one after another, so a repeat
|
|
994
|
+
queued behind a running press starts the moment that press *ends* —
|
|
995
|
+
which is why `toggle` stamps on the way out as well as on the way in.
|
|
996
|
+
A held key keeps refreshing the stamp and stays ignored until it is
|
|
997
|
+
released.
|
|
998
|
+
"""
|
|
999
|
+
now = time.time()
|
|
1000
|
+
try:
|
|
1001
|
+
last = float((CACHE_DIR / _PRESS_NAME).read_text(encoding="utf-8").strip())
|
|
1002
|
+
except (OSError, ValueError):
|
|
1003
|
+
last = 0.0
|
|
1004
|
+
_stamp_press()
|
|
1005
|
+
return now - last < _DEBOUNCE
|
|
1006
|
+
|
|
1007
|
+
|
|
970
1008
|
def toggle(stt: dict) -> int:
|
|
971
1009
|
"""One press of the dictation hotkey. Returns the process exit code."""
|
|
972
|
-
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1010
|
+
if _is_key_repeat():
|
|
1011
|
+
return 0
|
|
1012
|
+
try:
|
|
1013
|
+
workdir = Path(tempfile.mkdtemp(prefix=_WORKDIR_PREFIX))
|
|
1014
|
+
if _claim_session(workdir):
|
|
1015
|
+
return _start(workdir, stt)
|
|
1016
|
+
shutil.rmtree(workdir, ignore_errors=True)
|
|
1017
|
+
return _second_press(stt)
|
|
1018
|
+
finally:
|
|
1019
|
+
_stamp_press() # a repeat queued behind this press begins right now
|
|
977
1020
|
|
|
978
1021
|
|
|
979
1022
|
def _start(workdir: Path, stt: dict) -> int:
|
|
@@ -11,6 +11,10 @@ from __future__ import annotations
|
|
|
11
11
|
import shutil
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
|
+
# Where uv lives when it is on neither PATH nor in its own installer's
|
|
15
|
+
# spot (~/.local/bin): Homebrew on Apple silicon, then on Intel.
|
|
16
|
+
UV_FALLBACKS = (Path("/opt/homebrew/bin/uv"), Path("/usr/local/bin/uv"))
|
|
17
|
+
|
|
14
18
|
|
|
15
19
|
def uv_path() -> str | None:
|
|
16
20
|
"""uv's executable, or None. PATH first, then its default install spot.
|
|
@@ -21,5 +25,9 @@ def uv_path() -> str | None:
|
|
|
21
25
|
found = shutil.which("uv")
|
|
22
26
|
if found:
|
|
23
27
|
return found
|
|
24
|
-
|
|
25
|
-
|
|
28
|
+
# A Services (Quick Action) environment has a bare PATH, so the usual
|
|
29
|
+
# install spots are tried by name: uv's own installer, then Homebrew.
|
|
30
|
+
for fallback in (Path.home() / ".local" / "bin" / "uv", *UV_FALLBACKS):
|
|
31
|
+
if fallback.is_file():
|
|
32
|
+
return str(fallback)
|
|
33
|
+
return None
|
|
@@ -315,11 +315,13 @@ def selftest(
|
|
|
315
315
|
_RECORDER_DIR = Path(__file__).resolve().parent.parent / "recorder"
|
|
316
316
|
RECORDER_SOURCE = _RECORDER_DIR / "VocalizeRecorder.swift"
|
|
317
317
|
RECORDER_PLIST_TEMPLATE = _RECORDER_DIR / "Info.plist.in"
|
|
318
|
+
RECORDER_ENTITLEMENTS = _RECORDER_DIR / "Recorder.entitlements"
|
|
318
319
|
|
|
319
320
|
BIN_DIR = Path.home() / ".cache" / "vocalize" / "bin"
|
|
320
321
|
BUNDLE_NAME = "Vocalize Recorder.app"
|
|
321
322
|
RECORDER_STAMP_NAME = ".recorder"
|
|
322
|
-
|
|
323
|
+
# 3: the entitlements joined the fingerprint (0.10.0 signed without them).
|
|
324
|
+
RECORDER_STAMP_VERSION = 3
|
|
323
325
|
|
|
324
326
|
_BUILD_TIMEOUT = 600
|
|
325
327
|
|
|
@@ -369,6 +371,7 @@ def _recorder_fingerprint() -> dict:
|
|
|
369
371
|
"stamp_version": RECORDER_STAMP_VERSION,
|
|
370
372
|
"source_sha256": _sha256_of(RECORDER_SOURCE),
|
|
371
373
|
"plist_sha256": _sha256_of(RECORDER_PLIST_TEMPLATE),
|
|
374
|
+
"entitlements_sha256": _sha256_of(RECORDER_ENTITLEMENTS),
|
|
372
375
|
}
|
|
373
376
|
|
|
374
377
|
|
|
@@ -525,7 +528,7 @@ def build_recorder(
|
|
|
525
528
|
through a fake compiler: nothing here should ever need Xcode to be
|
|
526
529
|
installed on the machine running the suite.
|
|
527
530
|
"""
|
|
528
|
-
if not
|
|
531
|
+
if not all(p.is_file() for p in (RECORDER_SOURCE, RECORDER_PLIST_TEMPLATE, RECORDER_ENTITLEMENTS)):
|
|
529
532
|
raise InstallError(
|
|
530
533
|
"This install of vocalize is missing the recorder source "
|
|
531
534
|
f"({RECORDER_SOURCE.name}); reinstall vocalize to get it back."
|
|
@@ -570,14 +573,21 @@ def build_recorder(
|
|
|
570
573
|
# --options runtime is the hardened runtime, which under the ad-hoc
|
|
571
574
|
# signature refuses DYLD_INSERT_LIBRARIES — so nothing can inject a
|
|
572
575
|
# dylib into the one process on this machine holding a microphone
|
|
573
|
-
# grant.
|
|
576
|
+
# grant. The hardened runtime also refuses the microphone itself,
|
|
577
|
+
# silently and with no dialog, unless the signature carries
|
|
578
|
+
# com.apple.security.device.audio-input — hence --entitlements
|
|
579
|
+
# (0.10.0 shipped without it and no first dictation could start).
|
|
580
|
+
# It does *not* stop that bundle being launched directly:
|
|
574
581
|
# the grant belongs to the bundle, and anything running as this user
|
|
575
582
|
# can `open` it with its own --out and record. That is inherent to
|
|
576
583
|
# DEC-001 and is stated in docs/dictation.md § Privacy; there is no
|
|
577
584
|
# code mitigation, only `local uninstall --stt` plus revoking the
|
|
578
585
|
# grant in System Settings.
|
|
579
586
|
_run_build_step(
|
|
580
|
-
[
|
|
587
|
+
[
|
|
588
|
+
"codesign", "-s", "-", "--force", "--options", "runtime",
|
|
589
|
+
"--entitlements", str(RECORDER_ENTITLEMENTS), str(staging),
|
|
590
|
+
],
|
|
581
591
|
runner, _CLT_HINT, "signed",
|
|
582
592
|
)
|
|
583
593
|
_swap_in(staging, bundle)
|
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
# Roadmap
|
|
2
|
-
|
|
3
|
-
Future work, with the research behind each item. Status legend: **planned** = analysed, decisions pending, nothing built; **spiked** = feasibility measured; **building**; **shipped**.
|
|
4
|
-
|
|
5
|
-
| Item | Status | Tracking | Research |
|
|
6
|
-
|---|---|---|---|
|
|
7
|
-
| Hotkey-triggered local dictation (voice → text via a local Whisper model, Quick Action toggle, clipboard output, optional cleanup) | planned | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-dictation-design.md) · [voicebox findings](research/2026-09-01-voicebox-findings.md) |
|
|
8
|
-
| Config portal (`vocalize portal`, stdlib local web page with a readiness checklist) — or the one-day `vocalize status` screen first | planned | [#1](https://github.com/matthager12-collab/vocalize/issues/1) | [analysis](next-features-analysis.md) · [full design](research/2026-09-01-config-portal-design.md) |
|
|
9
|
-
|
|
10
|
-
## Open decisions (2026-09-01)
|
|
11
|
-
|
|
12
|
-
1. Run the 3-hour dictation spike? It measures Whisper (`base.en` / `small.en` / `large-v3-turbo-q5_0`) against Apple's on-device recognizer on the owner's own voice with developer jargon, plus latency and RAM on the 8 GB M3. If Apple's engine wins, the Whisper branch is deleted from the plan.
|
|
13
|
-
2. Portal (~50 h) or `vocalize status` (~1 day) first?
|
|
14
|
-
3. Hotkey chord — proposed ⌃⌥⌘D.
|
|
15
|
-
|
|
16
|
-
## Deferred on purpose
|
|
17
|
-
|
|
18
|
-
Hold-to-talk (needs an event tap + Input Monitoring, ~6 h), auto-paste (Accessibility, ~2 h, belongs in the recorder bundle), Apple STT mode (~1.5 h), menu-bar agent (~12 h), local-LLM cleanup, resident/pre-warmed STT worker (only if the spike shows >2.5 s overhead), a native SwiftUI/Tauri app.
|
|
19
|
-
|
|
20
|
-
## Sequencing suggestion
|
|
21
|
-
|
|
22
|
-
Dictation first (the spike decides the engine, then ~20 h), portal or `status` second.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/choreography.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/review-0.10.0.md
RENAMED
|
File without changes
|
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-1-status/report.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/run-5-resume/report.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/spike-2026-09-01.md
RENAMED
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/split-assessment.md
RENAMED
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/plans/2026-09-next-features/verification.md
RENAMED
|
File without changes
|
|
File without changes
|
{vocalize_cli-0.10.0 → vocalize_cli-0.10.1}/docs/research/2026-09-01-config-portal-design.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|