Skip to content

feat(ai): choose how a spoken question is answered, including AI voices - #1362

Merged
accesswatch merged 8 commits into
mainfrom
feat/voice-reply-settings
Aug 8, 2026
Merged

feat(ai): choose how a spoken question is answered, including AI voices#1362
accesswatch merged 8 commits into
mainfrom
feat/voice-reply-settings

Conversation

@accesswatch

Copy link
Copy Markdown
Contributor

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:

Mode Speech Cost Leaves the machine
Announce a short summary (default) screen reader free no
Show as text only none free no
Read aloud with QUILL's own voice Kokoro/Piper/eSpeak/DECtalk/SAPI free no
Read aloud with the AI voice OpenAI / Google Gemini billed per character yes

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 a VoiceReplyPlan the UI performs. The interesting rules are therefore 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.
  • Every degradation moves towards the offline default, never away from it.

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 a setting is lost.

Cloud voices are opt-in and never chosen for the user. ai_voice is 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_voice was surfaced in the generic settings list as one flat catalog of every provider's voices — so a Gemini voice could be selected while ai_tts_provider was 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

  • The default is exactly today's behaviour; nothing changes until someone opts in.
  • Voice conversation mode (Alt+Shift+Q) predates the setting and has always spoken its answers. 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 this change. The three rebaselined budgets are the settings registry itself (a new setting necessarily lands in settings.py + settings_specs.py) and one register() call; the rationale is written into module_size_budgets.json.

Verification

  • 14 new tests in test_voice_reply.py, including every mode parametrised, both degradation paths, and the never-both-speaks-and-announces invariant
  • Dialog inventory regenerated — the new surface registers as hardened_custom
  • 2734 passed across tests/unit/core + tests/unit/ui
  • ruff, ruff format, scoped mypy, GATE-11, GATE-12, banned-patterns, dialog escape-button contract, docs HTML/EPUB parity — all clean

One pre-existing failure is unrelated and confirmed by stashing: test_about_info::test_gather_about_info_default_channel_is_beta fails identically on a clean tree, caused by the local gitignored build/version.toml being 1.0.0 stable.

Docs

CHANGELOG, PRD §5.58, and the user guide; HTML/EPUB regenerated.

🤖 Generated with Claude Code

Jeff Bishop and others added 3 commits August 8, 2026 13:58
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>
@accesswatch
accesswatch requested a review from kellylford as a code owner August 8, 2026 22:00
Jeff Bishop and others added 5 commits August 8, 2026 15:07
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>
@accesswatch
accesswatch merged commit 62639d8 into main Aug 8, 2026
14 checks passed
@accesswatch
accesswatch deleted the feat/voice-reply-settings branch August 8, 2026 23:17
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant