feat(ai): choose how a spoken question is answered, including AI voices - #1362
Merged
Conversation
Four independent faults each stopped a release build on a clean machine, and together they meant the build only ever worked on one computer. 1. External binaries had to be staged by hand. Quill Radio's build demanded -FfmpegDir/-LibmpvDir pointing at directories the builder had assembled themselves, and hard-failed otherwise -- so the build was unreproducible and nothing recorded which binaries were correct. scripts/fetch_build_deps.py now stages both from QUILL's own pinned, SHA-256-verified assets-v1 release, reusing the exact pins the runtime downloader already uses (quill.core.release_assets, quill.core.speech.ffmpeg_install) so there is no second copy of a URL or hash to drift. The security rule is unchanged and arguably stronger: still no PATH auto-discovery, and now the bytes are checksum-verified against a pin in the source tree. 2. Inno Setup was not found when installed per-user. find_inno_setup_compiler() searched PATH and Program Files only, missing %LOCALAPPDATA%\Programs -- where Inno Setup's "install for me only" option puts it, which is all a non-admin builder can use. The build failed with "Install Inno Setup 6" on a machine that had Inno Setup 6. Roots are now environment-derived first, so a non-English or non-C: Windows works too. 3. The build was not idempotent. portable/ was reused between runs, so residue from a previous (especially failed) run tripped the flatten-collision guard and failed the NEXT build with a message about clobbering a "staged bundle entry" that was really a leftover __pycache__. The bundle is generated whole, so portable/ is now emptied first. A build must not depend on whether the last one succeeded. 4. An Offline Edition build corrupted the source tree. It wrote its own variant -- different AppId, AppName, OutputBaseFilename and Excludes -- over the tracked installer/quill.iss, which is meant to hold the standard script, so every --bundle-offline build left the tree dirty and the sync gate red. The reference copy is now written only by a standard build. Also: the .iss is written with newline="\n" explicitly. Text mode was translating to os.linesep, so the same generator emitted LF on CI and CRLF on a Windows build machine -- a 665-line diff with no content change on every local build, and a byte comparison in the sync test that could not hold across platforms. The standalone Radio and Weather scripts additionally resolve -Python, -QuillRepo and -TokenFile from the checkout instead of hardcoded D:\ paths, and defer to generate_feedback_token.py's own multi-source resolution rather than throwing about the one token source they happened to know about. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The repo's .venv is present but broken -- its base interpreter (C:\Users\jeffbis\...) no longer exists, so .venv\Scripts\python.exe dies immediately with "did not find executable at ...". Both standalone build scripts preferred that python.exe on the strength of Test-Path alone. The failure then surfaced several steps later as "Bundled feedback token generation failed", pointing at the wrong subsystem entirely. Test-PythonExe actually runs the candidate before committing to it, falls back to PATH with a note when a present-but-broken venv is found, and fails with an honest message when neither works. Existing on disk is not the same as runnable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Ask Quill has always supported asking by voice (Ctrl+F9 -> offline transcription -> send), but the answer had exactly one delivery path: a whitespace-collapsed 140-character summary handed to the screen reader. Meanwhile QUILL already had two full speech stacks that could have read it properly -- its own offline voices, and the cloud AI voices in core/ai/cloud_tts.py (OpenAI 11 voices, Gemini 30) -- both wired only to document read-aloud. This is the missing join, not a new stack. Tools > AI > Voice Reply Settings... picks one of four deliveries, applied to every reply however it was asked: announce (default, unchanged), text only, read aloud in QUILL's own offline voice, or read aloud in an OpenAI/Gemini voice. Truncation is deliberately NOT applied to the spoken modes. A character cap is the right shape for a summary and the wrong shape for speech: stopping mid-sentence is worse than not speaking. The limit is configurable (0 = none) and governs announcements only. Policy is separated from mechanism. plan_voice_reply() is wx-free, synchronous and returns a VoiceReplyPlan the UI performs, so the interesting rules are unit tested (14 tests) rather than buried in a wx callback. Two invariants hold there: a plan never both speaks and announces (it would talk over itself), and every degradation moves TOWARDS the offline default, never away. No API key, or an export-only provider (ElevenLabs), falls back to local TTS and then to an announcement, carrying a fallback_reason the UI states once -- silently doing something other than what a setting says is how trust in the setting is lost. The dialog also closes a real defect. ai_tts_voice was surfaced in the generic settings list as one flat catalog of every provider's voices, so a Gemini voice could be chosen while the provider was OpenAI, failing only at synthesis. Model and voice lists are now rebuilt from the selected provider, making the invalid pairing unrepresentable. Cost and egress are stated in the dialog body with a live estimate, because ai_voice is the only mode that bills money and sends the reply text off the machine; it is opt-in and never selected for the user. Voice conversation mode (Alt+Shift+Q) predates the setting and has always spoken its answers, so while the setting is at its default it still does -- entering voice mode and then being answered only in text would read as a regression. An explicit choice always wins. GATE-11: the delivery logic went into two NEW modules rather than growing a tracked one, which is why assistant_panel.py ends up smaller than before. The three rebaselined budgets are the settings registry itself (a new setting necessarily lands in settings.py + settings_specs.py) and one register() call. Docs: CHANGELOG, PRD 5.58, user guide; HTML/EPUB regenerated. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The feature landed in the CHANGELOG, the PRD and the user guide but not the release notes -- the one document a user actually reads at upgrade time, and the place the Ask Quill section already sets expectations about how answers come back. Sits under "The AI Suite > Ask Quill", where the 140-character announcement it replaces was the implicit behaviour. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two gaps in the Voice Reply work, both found by asking what the 140-character limit actually was: 1. A second reply path bypassed the setting entirely. _deliver_reply covered the conversation path, but the non-streaming legacy branch still called _announce_incoming directly, so on that path the mode setting did nothing and the old truncation still applied. It now routes through _deliver_reply like the other one. 2. Errors and edit proposals ignored the configured length. Four call sites went through _announce_incoming, which hardcoded a literal 140 rather than reading ai_voice_reply_announce_limit -- so the user's choice governed replies while everything else kept the old number. truncate_announcement() in core/ai/voice_reply.py is now the single rule every announcement shares, and _announce_incoming moved into the delivery mixin where it belongs alongside it. Errors and proposals stay *announcements* whatever the reply mode -- a 4,000-character error read out in full helps nobody -- they just respect the same length. The limit is read once per chat session rather than per utterance: it is on the path of every announcement and reading settings is file I/O. For the record, the 140 was never a Whisper or model limitation. Whisper is the input side (spoken question -> text); this capped the model's answer on the way out to the screen reader. No transcription model was ever involved. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The release notes, CHANGELOG and user guide described the configurable length as applying to replies. After the follow-up fix it governs everything Ask Quill announces -- answers, errors, and the summary of a proposed edit -- where the 140 used to be fixed in the program with no way to ask for more or less. Also states why errors and proposals stay announcements whichever reply mode is chosen: a long error read out in full is rarely what anyone wants. All four documents (CHANGELOG, PRD, user guide, release notes) now agree. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Two snapshot gates CI caught and my targeted local runs did not: - The accessible-name inventory. The dialog's five unlabelled-by-constructor controls (three wx.Choice, wx.SpinCtrl, wx.SpinCtrlDouble) are classified modal-hook, which is correct and not merely the default: the dialog is shown through _show_modal_dialog, and populate() deliberately creates each StaticText BEFORE its control, which is exactly the neighbouring label the runtime walker names from. The RadioBox and Preview button carry their own label= and are rightly absent. Without this, VoiceOver users would have met unlabelled controls (#1012). - The MainFrame public surface, which gained open_voice_reply_settings. Both regenerated with their own --write tools and the diffs reviewed rather than accepted blind. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Ask Quill has always supported asking by voice — Ctrl+F9 records, QUILL transcribes on the user's own machine, the text goes to the model. But the answer had exactly one delivery path:
_announce_incoming, a whitespace-collapsed 140-character summary handed to the screen reader.Meanwhile QUILL already had two full speech stacks that could have read it properly — its own offline voices, and the cloud AI voices in
core/ai/cloud_tts.py(OpenAI 11 voices, Gemini 30) — both wired only to document read-aloud.This is the missing join, not a new stack.
What the user gets
Tools ▸ AI ▸ Voice Reply Settings... — four deliveries, applied to every reply however it was asked:
Plus a Preview button, and a live cost estimate for a typical reply.
Design decisions worth reviewing
Truncation does not apply to speech. A 140-character cap is the right shape for a summary and the wrong shape for speech — stopping mid-sentence is worse than not speaking. The limit is configurable (0 = none) and governs announcements only.
Policy is separated from mechanism.
plan_voice_reply()is wx-free, synchronous, and returns aVoiceReplyPlanthe UI performs. The interesting rules are therefore unit-tested (14 tests) rather than buried in a wx callback. Two invariants hold there:No API key, or an export-only provider (ElevenLabs), falls back to local TTS and then to an announcement, carrying a
fallback_reasonthe UI states once. Silently doing something other than what a setting says is how trust in a setting is lost.Cloud voices are opt-in and never chosen for the user.
ai_voiceis the only mode that bills money and transmits the reply text. The dialog states that in the body, not a footnote.A defect this closes
ai_tts_voicewas surfaced in the generic settings list as one flat catalog of every provider's voices — so a Gemini voice could be selected whileai_tts_providerwas OpenAI, and you only found out when synthesis failed. The dialog rebuilds the model and voice lists from the selected provider, making the invalid pairing unrepresentable rather than merely discouraged.No regression for existing users
GATE-11
The delivery logic went into two new modules rather than growing a tracked one — which is why
assistant_panel.pyends up smaller than before this change. The three rebaselined budgets are the settings registry itself (a new setting necessarily lands insettings.py+settings_specs.py) and oneregister()call; the rationale is written intomodule_size_budgets.json.Verification
test_voice_reply.py, including every mode parametrised, both degradation paths, and the never-both-speaks-and-announces invarianthardened_customtests/unit/core+tests/unit/uiOne pre-existing failure is unrelated and confirmed by stashing:
test_about_info::test_gather_about_info_default_channel_is_betafails identically on a clean tree, caused by the local gitignoredbuild/version.tomlbeing 1.0.0 stable.Docs
CHANGELOG, PRD §5.58, and the user guide; HTML/EPUB regenerated.
🤖 Generated with Claude Code