Repository navigation
TTP 3.2.3: reliable long dictations, silent updates, any shortcut - #5
Merged
Merged
Conversation
Fixes the three polish bugs the user kept hitting:
1. "demain, après-demain" stays as "demain après-demain" → resolved
2. Hesitations ("euh euh ouais") not cleaned → resolved
3. "écris un poème" executed by the LLM instead of pasted → resolved
## What ships
- New anti-injection system prompt frames the LLM as a deterministic
text-cleanup function (not an assistant) and treats <dictation> content
as INERT DATA. Defeats RLHF "be helpful, follow imperatives" prior.
- XML tag wrapping <dictation>…</dictation> with input sanitization that
neutralizes tag-break attacks.
- Single-call structured JSON output {intent, polished}. No second
classifier round-trip — intent is captured alongside the polished text.
Five intents: raw_prompt, code, list_or_enum, form_field, natural_text.
- temperature = 0.0 (deterministic transformation, not generation) and
max_tokens cap = input × 1.3 + 50 so an LLM that tries to write a poem
in response to "write me a poem" gets truncated and the guards reject
the garbage downstream.
- Post-LLM guards in three layers: length ratio ∈ [0.4, 1.5], content-word
Jaccard ≥ 0.55 (FR + EN stopwords), refusal-marker blacklist. Rejection
falls back silently to the phase-1 cleaned transcript and emits a Sentry
breadcrumb — no mid-dictation UI popup.
- New `cleanup` module: deterministic Rust pass BEFORE the LLM. Handles
punctuation commands (FR + EN), tech dictionary normalization
(type script → TypeScript, etc.), acronym joining (A P I → API),
bigram/unigram repetition collapsing that preserves x3+ emphasis,
truncated prefix merging, standalone filler removal, whitespace
normalization. Idempotent.
- Removed strip_llm_wrapper() prefix-matching hacks — obsolete now that
the LLM returns structured JSON.
- Personal dictionary no longer injected into the polish prompt; the
hard apply_dictionary post-process still runs as a guarantee.
- Sentry breadcrumbs track polish latency, intent classification, and
guard rejection reasons (groundwork for product-level observability).
## Tests
43 unit tests across polish + cleanup modules pass locally. 27 golden
integration fixtures (FR + EN, jailbreak + self-correction + benign +
code-switching) under `src-tauri/tests/fixtures/polish_golden.json`,
runnable via `GROQ_API_KEY=… cargo test --test polish_golden -- --ignored`.
The previous filter had "merci d'avoir regarde cette video" hardcoded
WITHOUT accents, but Whisper actually returns the phrase WITH accents
("regardé", "vidéo"). Lowercasing alone doesn't strip accents, so the
filter was effectively inoperative on the #1 most frequent French
hallucination. Same blind spot for typographic apostrophes (U+2019).
What changed:
- Added `unicode-normalization` dep (NFKD + combining-mark strip)
- New `normalize_for_hallucination_match` that lowercases, strips
accents, normalizes curly quotes/dashes/ellipsis/NBSP, drops
apostrophes and internal sentence punctuation (so "vidéo, n'hésitez"
matches the canonical list entry without the comma)
- Enriched HALLUCINATIONS list with FR/ES/DE/IT/PT/JA/ZH/RU/AR/KO/EL
variants of "thanks for watching" plus all common YouTube end-cards
- Dropped dangerous single-word false positives ("thank you", "bye",
"goodbye", "see you", "subscribe", "i'm sorry") — users genuinely
dictate these in real emails. Kept the truly-noise single tokens
("you", "so", "the", "oh", "okay", "uh") since those are virtually
never legitimate full transcripts.
- Added 3-gram repetition loop detection — catches Whisper's
large-v3-turbo classic failure mode ("thanks for watching thanks
for watching thanks for watching")
- 11 new unit tests covering accent stripping, typographic
apostrophes, trailing punctuation, signatures, repetition loops,
legitimate user speech (must not filter), Spanish/German variants,
long French promo with internal comma
Sources: HF dataset sachaarbonel/whisper-hallucinations, arXiv
2501.11378, openai/whisper discussions #679 / #928 / #1455 / #1873.
Reviewed every exact-match entry for "would a user genuinely dictate this as a standalone short message?" and trimmed the list where the answer is yes. Better to let an occasional Whisper hallucination through than to filter real user speech. Removed from exact-match: - "okay", "yeah", "you", "so", "the", "oh" — common Slack/iMessage quick replies. Whisper does emit these as single-token noise on silence, but the cost of filtering a real "Okay" reply outweighs the benefit. - "bonne journée", "bonne soirée", "à bientôt", "à la prochaine" — these are legitimate French email/message closings users dictate often. Kept only: - "uh", "um", "hmm" — pure disfluency-shaped tokens that are virtually never legitimate full transcripts (the match is whole-string, so an utterance containing them mid-sentence is unaffected). Added 2 new tests: - `does_not_filter_real_short_replies` (Okay / Yeah / Yes / No / Sure / So / You / The / Oh) - `does_not_filter_french_email_closings` (Bonne journée / Bonne soirée / À bientôt / À la prochaine / Au revoir / Salut / Voilà / Merci) 56 tests pass.
The marketing version stays "3.0.0" across all v3.0.0-beta.N tags (Windows MSI bundler rejects non-numeric pre-release suffixes), so the user has no way to tell which beta build they're running. Fix that by embedding the git commit SHA at build time and showing it next to the version in Settings → Updates. Display format: • Stable build: "v3.0.0 · cd672e1" • Beta build: "v3.0.0 · cd672e1 · beta" • Old beta < .4: "v3.0.0" (no SHA — pre-feature) Implementation: - build.rs reads GIT_COMMIT_SHA env var first (CI can pass github.sha explicitly if desired), falls back to `git rev-parse --short=7 HEAD` for local dev builds, falls back to "unknown" if neither works. Emits as cargo:rustc-env=TTP_BUILD_SHA so env!() works in Rust. - New `get_build_info` Tauri command returning { version, commit_sha, channel }. - Settings UpdatesCard calls it once on mount and appends the SHA and optional " · beta" suffix to the existing version label. The channel comes from settings.use_beta_channel, so it reflects the user's *preference* not the binary's origin. The SHA is the source of truth — a stable user running a beta build would see "v3.0.0 · cd672e1" without the "beta" tag but the SHA still pins them to the exact build.
Skipping v3.0.0 because the v3.0.0-beta.1..4 binaries all reported
version "3.0.0" (Windows MSI bundler rejects non-numeric pre-release
suffixes, so the manifest carried no -beta.N). That left the auto-
updater unable to differentiate beta builds from each other or from
stable v3.0.0 (strict semver: "3.0.0 > 3.0.0" is false). Bumping to
v3.0.1 makes the update path obvious for everyone:
- v2.2.2 stable users → 3.0.1 > 2.2.2 → auto-update fires
- v3.0.0-beta.X users → 3.0.1 > 3.0.0 → auto-update fires
- 1 CI run instead of forcing manual reinstalls
Going forward, pre-release tags use numeric-only suffix (e.g.
v3.1.0-1, v3.1.0-2, v3.1.0). MSI bundler accepts these since
tauri-bundler v1.2.0, the NSIS-only Windows hack can stay as
belt-and-braces. release.yml beta detection regex now matches BOTH
-N (new convention) and -beta.N (legacy v3.0.0-beta.X tags still in
the wild).
What ships in this release:
- Anti-injection polish prompt with INERT-DATA framing + XML
wrapping + structured JSON {intent, polished} output
- Phase-1 deterministic cleanup (Rust regex/dict before LLM):
punctuation commands, tech term normalization, acronym joining,
repetition collapsing, standalone filler removal
- Post-LLM guards (length ratio, content-word Jaccard, refusal
markers) with silent fallback to phase-1 output
- Whisper hallucination filter with NFKD accent stripping, multi-
language signature catalog (FR/ES/DE/IT/PT/JA/ZH/RU/AR/KO/EL),
3-gram repetition loop detection
- Anti-overprotection: real Slack/iMessage quick replies and
French email closings pass through untouched
- Git commit SHA embedded at build time, shown in Settings → Updates
("v3.0.1 · 52ec0b2 · beta") so users can tell exactly which build
they're running
- Sentry breadcrumbs for polish latency, intent classification, and
guard rejections — groundwork for production observability
Two Fn-key annoyances: - Pressing Fn opened the system Globe/emoji picker every time. The HID event tap was listen-only/tail-append so it could observe but not swallow the Fn FlagsChanged event. Switch to an active head-insert tap (when Accessibility is granted) and return null on the Fn event to consume it. Gated on fn_key_enabled; falls back to the passive tap with no suppression when Accessibility is missing. fn+key combos still work — the Function flag rides on each combo key's own event. - In hands-free mode a single quick Fn tap was ignored (treated as too-short / a double-tap candidate); stopping required a re-double-tap or a long hold. Now a single tap stops an active hands-free recording, guarded by a 400ms grace window so the starting double-tap can't self-cancel. shortcuts.rs reports hands-free state to the Fn monitor. Also re-enable the tap if macOS disables it on timeout / heavy input.
The v3.0.2 CGEventTap approach to swallow the Fn key couldn't suppress the
macOS Globe/emoji picker — the WindowServer reads the Globe key below the
event-tap layer. Revert that dead, risky active-tap code back to the passive
listen-only tap (the single-tap-stop hands-free fix stays).
Instead, guide the user. The real lever is com.apple.HIToolbox AppleFnUsageType
= 0 ("Do Nothing"), which disables the picker live and keeps Fn key events
flowing. A programmatic write persists but doesn't apply to the running session
(no public signal to force a re-read), so we don't write it silently.
- fn_globe_key_intercepts: reads AppleFnUsageType via a fresh `defaults`
process so external changes are seen immediately.
- open_keyboard_settings: deep-links to the Keyboard pane.
- FnEmojiNudge: warning card under the recording-trigger picker, shown only
when Fn is the trigger and the Globe key still intercepts. Auto-dismisses on
window focus + a 2s backstop once the user sets "Do Nothing".
…no-speech path
The upstream tauri-plugin-mic-recorder v2 produced 68-byte empty WAV files
on 11/12 of the user's recordings (header only, zero audio samples), which
Groq Whisper then rejected with a generic "Audio file is too short" error.
Three structural defects in the plugin's audio callback caused this:
- try_lock() in write_input_data silently dropped samples on contention
- write_sample(...).ok() swallowed every hound write error
- err_fn = |e| eprintln!() — fatal cpal stream errors were never
surfaced to the user (notably, mic permission silently revoked
after an unsigned-app update)
Replaces the plugin with src-tauri/src/audio_capture.rs, mirroring the
existing audio_monitor.rs pattern. Key differences:
- Pre-flights AVCaptureDevice authorization status before building the
cpal stream; bails with a "permission" marker error so the frontend
surfaces error.microphone_permission_denied instead of an empty WAV.
- Audio callback uses blocking lock() — the only other writer-mutex
holder is stop_recording's take(), so contention is bounded to a
single short hold; never drops samples.
- cpal err_fn propagates StreamError via the audio-stream-error event
(already handled in useRecordingControl.ts).
- AtomicU64 sample counter logs explicitly when zero samples were
captured.
Defense in depth in the transcription pipeline so a future capture-side
bug never reaches Groq as an empty upload:
- New backup::wav_duration_secs() catches WAVs with valid headers but
zero audio samples (test fixture is byte-for-byte identical to one
of the user's actual failed 68-byte recordings).
- error.recording_empty / notification.recordingEmpty i18n keys give
the user an actionable pill instead of "Transcription failed".
Latent bugs fixed at the same time:
- has_repetition_loop now requires tight proximity (≤8 words apart)
instead of global 3-gram counts; natural 3-minute monologues with
repeated filler phrases ("et donc je") are no longer flagged.
- Empty Whisper response retried once (Groq occasionally returns
200 OK with empty body on long monologues).
- Dictionary-ghost filter gated to recordings <10s; on long recordings
a 1-2 word result is more likely a degenerate transcription worth
pasting than a silence hallucination.
Drops: tauri-plugin-mic-recorder (Cargo.toml), tauri-plugin-mic-recorder-api
(package.json), mic-recorder:* capabilities, plugin init in lib.rs.
Massive multi-wave pass driven by the 409-finding code/UX/security audit
captured in .planning/audit-results/. ~110 frontend tests + ~90 Rust tests,
TS clean, i18n parity + key-existence checks green.
## Bug fixes (audit blockers)
- Trial countdown mismatch: backend exposes `trial_ends_at`; onboarding
uses it instead of hardcoded 7d (Rust grants 4d).
- process_audio: canonicalize + prefix-check vs recording dir, plugs
arbitrary file exfiltration via the IPC.
- Stop transcript leakage to stderr: hallu filter + dictionary classify
redact content, only log char counts.
- Settings: in-memory 5s TTL cache, atomic write (temp + fsync + rename),
refresh `.bak` AFTER the new file is durably in place.
- Sentry: manual panic capture compatible with `panic=abort`,
`pipeline_stage` scope cleared on terminal stages.
- Disk error scrubbing: every eprintln converted to log_*; dictionary
corruption quarantined as `.corrupt`.
- audio_capture: error code strings (`error.microphone_permission_*`)
instead of raw English.
- macOS locale detection via `defaults read -g AppleLocale` (was POSIX
env only — missed most FR Mac users).
- Privacy copy: removed "anonymous usage events" (analytics is no-op),
truthful key+voice split, em-dashes purged.
- LEGACY_HMAC_SECRET deprecation marker: per-account keychain flag,
closes pre-install plant vector.
- Landing: dropped Aptabase, fixed JSON-LD softwareVersion (1.8.1→3.0.4),
em-dashes removed, source-available copy.
- CI: pinned Rust toolchain 1.85.0, beta-tag regex tightened, tag-vs-source
version sanity check, Windows signtool wired via thumbprint inject,
opus brew step dropped (unused).
- HTTP capability: dropped unused github.com/api.github.com entries.
## New features
- VAD auto-stop (opt-in): polls the shared RMS bucket, fires
handle_shortcut_event_public(Released) after 1-10s of silence.
Settings → Behavior toggle + range slider.
- Audio device picker: cpal enumeration via list_audio_input_devices.
Settings → Behavior dropdown. Falls back to OS default on hot-unplug.
- Quick history replay: replay_history_entry types/pastes past entries
into the focused app via the existing paste pipeline.
- Hard recording duration cap (5min): enforced from audio_monitor.
- Hands-free lock indicator on pill: Rust emits recording-mode-changed,
FloatingBar shows a Lock icon during hands-free sessions.
- Single cpal stream: audio_capture now computes RMS inside its WAV
callback; audio_monitor polls the shared atomic instead of opening
a second cpal stream (fixes recurring "second-stream race" failure).
- State session-override split: `session_hands_free: Option<bool>` for
transient overrides; `hands_free_mode` reserved for the persisted
preference. Cleared automatically on every Idle transition.
- fnkey FSM extraction: pure `fn_decide(state, fn_held, now_ms) -> action`
in fnkey_fsm.rs, all timing rules in one testable place.
- Reveal recordings folder + reveal log folder IPC commands.
- Settings → Diagnostics section with both buttons.
- Logging level filter (TTP_LOG_LEVEL) + 2-stage rotation.
## Frontend test infrastructure
- vitest + happy-dom + @testing-library/react + jest-dom wired.
- tsconfig split so build tsc skips tests; tsconfig.test.json owns vitest globals.
- 109 tests across 12 files: translateRustMessage, safeInvoke, settings-store,
updater-decisions, audio-stream-error, useTranscription, useUpdater,
useRecordingState, useTauriEvent, theme, i18n-config, pii-scrub.
## Rust tests added
- licensing/storage HMAC roundtrip + tamper (8)
- usage/store signature input pinning + trial day math (10)
- settings/store atomic write + cache TTL (5)
- pipeline transcription + polish classifiers (8 + 4)
- fnkey_fsm decision FSM (16)
- dictionary apply_dictionary_to_text (13)
- backup validate + duration (3)
- licensing is_pro_at offline grace (7)
- audio_capture rms_from_* + store/read (8)
- vad vad_step + required_ticks (9)
- convert low_pass_filter passband/stopband/DC/silence (5)
- state AppState methods + transitions (6)
## Docs + meta
- THREAT_MODEL.md: in-scope vs out-of-scope security boundaries.
- docs/architecture.md: full runtime diagram + state contracts.
- CONTRIBUTING.md: 3 build checks, conventions, areas needing help.
- README rewritten: source-available framing, VAD/hands-free features,
test commands.
- SECURITY.md: updater key recovery + rotation playbook.
- LICENSE: source-available, non-commercial.
- scripts/check-i18n-keys-used.mjs: validates every `t('xxx')` callsite
resolves against a real en.json key. Wired into npm run build.
…n with --bundles)
Two paths could leave the recording state machine stuck after a quick double-tap on Windows. Reported by a user on v3.1.0-1 who could no longer trigger any recording after a single Ctrl+Space double-tap and had to restart the app. 1. process_audio early-return Errs bypassed set_state(Idle). The state machine reaches Processing the moment shortcuts::stop_recording fires (well before process_audio runs), so any Err here strands the app in Processing forever — and handle_shortcut_pressed is a no-op outside Idle/Recording. The very-short WAV produced by a quick press+release pair was hitting one of these early returns (likely the Windows- canonicalize \\?\-prefix path comparison on a recording shorter than the system could finalize). Now every Err path emits the user-facing error pill AND resets state to Idle before bubbling up. 2. shortcuts::handle_shortcut_pressed didn't clear last_shortcut_time after detecting a double-tap. fnkey.rs already had this guard (LAST_FN_PRESS_TIME_MS.store(0)) but the Ctrl+Space path was missing it, so the immediately-following single press within 300 ms was being re-interpreted as the third tap of a triple-tap. Cleared now. JS/Rust test sweep: tsc 0 errors, vitest 109/109, i18n parity 350 keys.
… + lang detect)
1. Tray spam-click stuck in Recording
- 300ms debounce on tray::toggle_recording (LAST_TRAY_TOGGLE_MS atomic)
- audio_capture::start_recording self-heals: drops a stale STATE
instead of returning "recording_already_in_progress" forever
2. Foreign-language transcriptions on silence
- whisper.rs::transcribe_audio gains a `language` parameter
- pipeline passes the user's UI language (en/fr) so Whisper's per-frame
auto-detect can't fall into zh/ru/ko on noise
3. "Thank you" + "Sous-titré par X" on silent recordings
- backup.rs::wav_average_rms (full-file RMS)
- pipeline silence pre-check: if avg RMS < 0.005, skip Whisper entirely
and emit error.no_speech (kills the Whisper-on-silence hallucination
class without needing to add bare "thank you" to the static list)
4. Subtitle-credit signatures Whisper bleeds onto short noisy clips
- HALLUCINATION_SUBSTRINGS gains "sous titre par", "captions by",
"subtitles by", "untertitel von", "subtitulos por", and a handful of
other studio-credit STEMS so any future studio name is also filtered
…ase...") User reported on v3.1.2-1: silent recordings sometimes produce sentences like "Glossary, c'est une phrase qui est très importante." or other "glossary"-themed output. Root cause: the Whisper prompt the pipeline builds to bias toward the user's dictionary proper nouns started literally with the word "Glossary:". The decoder picked up that introducer word and, on near-silent frames, wrote a full sentence ABOUT glossaries because its training corpus has plenty of paragraphs like that. Fixes: - Drop the introducer word entirely from the prompt. The proper-noun list is appended after the bilingual hint with NO label. Whisper still biases toward those nouns from their presence in context. - Safety net in pipeline: short recordings (≤ 6 s of audio, ≤ 8 words) whose leading word is `glossary` / `glossaire` / `vocabulary` / `vocabulaire` / `lexique` are filtered as "no speech" — same as the existing single-dict-word "glossary ghost" check, extended to the prompt-introducer-leak case. Belt-and-suspenders so a legitimate dictation like "I need to update the team glossary tomorrow" (longer, with the word mid-sentence) still passes.
…eaking into output (Whisper prompt-bias) Two field bugs reported on v3.1.3-1 (Windows): 1. End of message cut off WASAPI on Windows (and several CoreAudio configurations) hold up to ~150 ms of captured input in an internal buffer between the device and the cpal callback. stop_recording was dropping the stream immediately on key release — the trailing buffer was lost, surfacing as a chopped-off last syllable / word. Added a 200ms tokio sleep BEFORE drop(stream) in stop_recording. 200ms is generous vs the largest observed trailing buffer; it disappears into the Whisper round-trip downstream. 2. Dictionary words appearing at the end of transcriptions v3.1.3 dropped the "Glossary:" introducer from the Whisper prompt but still listed proper-noun corrections inline. Whisper then occasionally appended those nouns to the end of legitimate transcriptions (e.g. "thanks for the help, Amir, OAuth, Supabase") — classic prompt-bias trailing-token leak. Dropped the proper-noun list from the prompt entirely. The prompt is now just "French and English bilingual speaker." (still useful as a FR/EN per-frame hint paired with the language= API parameter). Proper-noun correction is unaffected: the post-transcription apply_dictionary pass does word-boundary substitution and NEVER adds words not present in the transcription.
User feedback on v3.1.4-1:
* "ça me coupait à chaque fois la fin" — the 200 ms drain wasn't enough
on the user's Windows hardware to recover the trailing buffer.
* "on devrait sélectionner la langue principale qu'on veut retranscrire"
— the implicit UI-language → Whisper-language coupling forced a wrong
pin for a bilingual speaker using TTP in one locale but dictating in
another.
Changes:
1. New `Settings.transcription_language: Option<String>` ("auto" | "en" |
"fr" | null). Dropdown in Settings → Transcription. Defaults to "auto"
(Whisper auto-detect — recommended for bilingual users). pipeline.rs
now reads this setting directly instead of inferring from the UI lang.
The silence pre-check (RMS gate) still protects against the zh/ru/ko
hallucinations on silent audio that motivated the original pin in
v3.1.2.
2. audio_capture::stop_recording drain delay bumped 200ms → 400ms. Some
WASAPI drivers hold more than the 150ms we sized for originally. 400ms
still disappears under the Whisper round-trip (1-2s downstream).
i18n keys: settings.transcription.languageLabel / languageDesc /
languageAuto / languageEnglish / languageFrench (EN + FR).
settings-store + Settings UI wired. tsc 0, vitest 109/109, i18n parity
355 keys.
User reported on v3.1.5-1: a legitimate French sentence about archiving
projects was capped with "...tu vois ce que je veux dire ? English
bilingual speaker. Claud" — Whisper had copied the tail of my supposedly-
minimal prompt ("French and English bilingual speaker.") onto the
trailing silence of the recording.
This is the FOURTH iteration of the prompt-bias bug:
- v3.1.2-1: "Glossary: <names>" → sentences ABOUT glossaries on silence
- v3.1.3-1: dropped introducer, kept inline name list → names at the
end of valid transcriptions
- v3.1.4-1: minimised to "French and English bilingual speaker." → STILL
leaked, just less obviously
- v3.1.5-1: unchanged
- v3.1.6 (here): pass `prompt=None`. Every variant of a prompt leaks.
The `language` API parameter (with the Auto / EN / FR setting added in
v3.1.5) handles bilingual decoder pinning without a prompt. The post-
transcription `apply_dictionary` pass handles proper-noun substitution
via exact word-boundary match — it can NEVER add words that weren't in
the transcription, so it has zero hallucination surface.
Going forward: any future re-introduction of a Whisper prompt must
demonstrate it cannot leak on silence (which is empirically impossible
for free-form text prompts).
Settings → Advanced → Report a problem writes a text report to Downloads (version, macOS, permissions, settings, dictionary/history counts, the last 800 trace lines with every "text" field removed at any depth), reveals it in Finder and opens a pre-addressed e-mail to amirksmain+ttp@gmail.com. Nothing is sent without the user. The onboarding's last step asks once whether to send crash reports: off until switched on, saved as a one-key settings payload. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Users did not know that names from their screen travel with the dictation. The setup's last step now has the switch, on by default, with the sentence that says what is sent, to whom, and that passwords never are; the Settings hint says the same. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…ettle Measured over 23 dictations (2026-09-15), release to text took 1.95 s at the median, and 0.52 s of it was fixed sleeps: a 400 ms drain after every key release (a Windows WASAPI fix applied on every platform) and a 100 ms focus settle before typing, on a path where focus never moves. The drain now polls the sample counter and stops once the capture covers the release plus 30 ms, capped at the old 400 ms (capture.stop.drain_ms). The settle is 25 ms. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…(gpt-oss-20b) Polish is the largest share of the wait (0.81 s median of 1.95 s on 2026-09-15). Fast uses openai/gpt-oss-20b: same family and parameters, about twice the throughput on Groq. Accurate stays the default because the golden suite has only been measured against 120b. The model in use is on settings.snapshot (polish_speed) and on every polish trace line. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…m for Fast Onboarding gains a step before the try: what reading the screen does (cloud code → Claude Code), what goes to Groq, what never does, and two buttons — Turn on / No thanks — with no Next. The setting now defaults to false, so nothing from the screen leaves the Mac without that yes. The switch at the end of setup is gone; Settings keeps its explained toggle. gpt-oss-20b ran out of completion tokens on a 449-character dictation (json_validate_failed) on its first day; it gets 1024 extra tokens of reasoning headroom. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A day of use (2026-09-15): gpt-oss-20b answered in 0.68 s median against 0.74-0.81 s for 120b, left a self-correction unresolved, and ran out of completion tokens on a long dictation. The spikes are Groq's, not the model's, and the setting cost more than it gave. Back to one model. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The two holes docs/tracing.md still listed, closed before Amir's pre-release test days: - ui.* records from the JS side via trace_api::trace_ui: uncaught errors and rejections in every window, and the recording handoff failures. Recordings under 0.3 s were dropped by the frontend with no trace at all; that is now ui.recording.too_short. Stages must be ui. slugs, text fields are stripped and strings capped at 200 characters. - settings.changed on every settings write, naming the keys that changed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
One sentence, the download, and a desktop where the real pill dictates into three apps. Native scrolling only: no Lenis, no snapping. Content matches the app today: macOS only, screen context opt-in, the licence is five sound packs, no face, no coats, no manual. Photo from amirks.eu, link-preview images for LinkedIn, legal pages brought up to date. CI: pushes that only touch landing/ no longer build the app. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A dictation that finishes while the next one is being recorded is held until the hotkey comes up — typing under a held modifier turns letters into shortcuts. The pill showed only the new waveform, so the held text looked lost (10.7 s on 2026-09-16, trace 0060-9698). paste/simulate.rs now emits `paste-waiting` around that wait and the pill adds "collé quand tu lâches" / "pastes when you let go" after the timer. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Screen context is now asked for during setup, setup says where the voice goes, the end of a dictation is faster, and the pill says when a previous text is waiting for the key to come up. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Read aloud on 2026-09-16 (trace 0076-6510): Whisper heard "3.2.2" five
times and the deterministic cleanup pasted "3.2" each time. Replaying the
43 dictations it had changed in the maintainer's trace found the rest:
- Repetition collapsing split on any non-word character, so "3.2.2",
"moi ? Moi" and "de % de" counted as repeated words. Only words
separated by whitespace count now.
- The bare "point"/"period" commands turned "du point de vue", "le point 5"
and "point important" into "du . de vue"… Removed; the polish model
still handles a spoken "point" in context.
- "ah" was a filler, leaving "! Excellent". Kept now. "Euh..." left "..";
the ellipsis now goes with the filler.
Replayed after the fix, only whitespace-separated repeats ("très très")
still collapse, as designed.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…stes Three things Amir hit on 2026-09-21. 1. Alt-tabbing to the Settings window froze every click for 1.5–3 s. Sampled: the main thread sat in AXUIElementCopyAttributeValue under the IPC handler. `check_accessibility_permission` runs on every window focus, its probe asks the system-wide element for the focused app, and an Electron app whose accessibility tree is asleep takes seconds to answer. The trace showed it as `hotkey.timer_stall` 3023/1531/1279 ms each time he opened Settings. The probe now has a 250 ms messaging timeout (as screen_context already does) and the command is async, so it runs off the main thread. `fn_globe_key_intercepts` (spawns `defaults`, polled every 2 s) is async for the same reason. 2. Screen context read TTP's own previous paste as "names on screen": Whisper's "Keloosidum" came back beside the right "Kellou-Sidhoum" and the polish kept the wrong one (0337-0a58). The pipeline now remembers the last 8 pasted texts (15 min) and drops terms made only of words it pasted itself; the trace counts them (`own_terms_dropped`). The dictionary's spellings are still added afterwards. 3. He did not know the transcription-language setting existed. A hint under it says what Auto-detect is for (mixing French and English). Also ignore `promo/` (the Remotion launch video) and `.env`. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
…other script A surname dictated alone in auto mode came back as Turkish, "Kırı siduğu." (0001-0c38, 2026-09-21): on a one-second clip Whisper's language detection guesses, and nothing downstream can recover a name written in the wrong script — the screen's "Kellou-Sidhoum" sounds nothing like it. The script gives it away. `has_foreign_script` flags any letter outside what French and English use (ASCII, Latin-1, œ Œ Ÿ); on a hit, and only in auto mode, the pipeline asks Whisper once more with the language pinned to the app's (`whisper.retry reason=foreign_script`), and the second `whisper.response` carries `language_retry`. Digits and punctuation are ignored; ñ, ø, å stay ours. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Amir, on the Turkish "Kırı siduğu." for his surname: "je parle anglais et
français, c'est tout." Auto-detect should choose between the two
languages TTP's users speak, not among Whisper's ninety-nine.
Whisper now answers in `verbose_json`, which names the language it
chose; `Transcript { text, language }` carries it and `whisper.response`
records it. In auto mode, when that language is neither French nor
English — or Whisper said nothing and the script is foreign — the
pipeline asks once more with the language pinned to the app's
(`whisper.retry reason=language_not_fr_en detected=Turkish lang=fr`).
The Settings option now says so: "Français et anglais (auto)".
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Whisper picks one language per dictation and, when it picks French, may translate an English passage (0002-00a8, 2026-09-21: English first, French after, everything came back French). The hint no longer promises mixing inside one dictation; it says Whisper follows the main language. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
A 46.7 s dictation peaking at 0.31 averaged 0.00489 RMS because of long pauses and was deleted as silent (trace 0152-dce8, 2026-09-23). The gate now drops a recording only when its average is under the floor AND it holds under 300 ms of 50 ms windows above max(0.008, 4x noise floor). A dropped WAV is kept in audio_backups/ for 24 h; audio.signal and silent_audio carry speech_ms, noise_floor and backup_kept. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Quiet real speech (21:51 clip, 2026-09-23) sat around 0.005 per 50 ms window and was dropped with 150 ms counted; a 17 s dictation full of speech counted exactly the 300 ms minimum. audio.signal and silent_audio now carry window_p50/window_p90 to calibrate on real voices. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…utes A found update was downloaded and installed at once, replacing the bundle under the running process, and the auto-restart was disabled for anyone who had dictated in the session. TTP then ran old code over a new signature and could lose the mic or accessibility until quit. Now the bytes are staged in memory; the bundle is only replaced by apply_staged_update, which relaunches in the same step. The frontend applies it after two minutes Idle (any dictation re-arms the timer); Settings and the tray apply it on demand. update.* events trace each step. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The background checker runs in the main window, which never loads settings, so it always asked the stable manifest: beta subscribers never received a beta automatically. build_channel_updater now ORs the caller's flag with settings.use_beta_channel. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nutes The webview timer never fired on Amir's Mac: TTP is an LSUIElement agent, macOS App Naps it, and the hidden main window's timers stop. The beta was staged at 07:20:50 on 2026-09-24 and nothing applied it. start_idle_applier now runs in Rust, holds an App Nap assertion while an update is staged, and applies it once state::quiet_for() (every state transition and dictation stage stamps activity) reaches 120 s. A tick that wakes late is skipped so a wake caused by a key press never relaunches. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first idle update opened the Settings window with What's New in the middle of Amir's work. apply_staged_update now leaves a marker; the next launch consumes it, traces update.completed (from, expected, now, landed) and, when the trigger was idle, shows a notification and leaves the note for the next time Settings is opened. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Marketing and purchase research, the overhaul brief and status ledger, the companion design and manual, and the TTP Pro decision move to a private folder. Code comments no longer point at files that are gone. Drop a tracked .pyc and ignore Python bytecode. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
… templates README: no Buy Pro link, say plainly that voice goes to Groq, lead with accuracy on long dictations, describe the new update behaviour, fix the polish model, test counts and project tree, and show the product image. Release pages said only "See the assets below". scripts/release_notes.py renders the app's What's New note as the release body, and the publish job writes it on every stable tag. Add bug and feature issue templates. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
AmirK-S
marked this pull request as ready for review
September 24, 2026 08:06
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.
Brings
masterup to the code shipped as v3.2.3. Work happens onpolaris/v3.0.0; releases are tagged there and built by CI.Highlights since the last merge:
docs/tracing.md).Tests: ~150 frontend (vitest), ~470 Rust (
cargo test --lib).🤖 Generated with Claude Code