Skip to content

TTP 3.2.3: reliable long dictations, silent updates, any shortcut - #5

Merged
AmirK-S merged 149 commits into
masterfrom
polaris/v3.0.0
Sep 24, 2026
Merged

AmirK-S merged 149 commits into
masterfrom
polaris/v3.0.0

Conversation

@AmirK-S

@AmirK-S AmirK-S commented Jun 10, 2026 •

Copy link
Copy Markdown
Owner

Brings master up to the code shipped as v3.2.3. Work happens on polaris/v3.0.0; releases are tagged there and built by CI.

Highlights since the last merge:

  • Dictation reliability: the silence gate counts 50 ms speech windows, so long dictations with pauses or quiet speech are no longer dropped; a rejected recording is kept for 24 h. Every stage of a dictation is traced (docs/tracing.md).
  • Updates without interruption: updates are staged in memory and applied from Rust after two quiet minutes, with an App Nap assertion; the beta channel now works for automatic checks.
  • Any shortcut: Fn, a key, a modifier or a mouse button, plus an optional second one.
  • Screen context (opt-in): nearby text helps spell names; TTP's own pastes are ignored.
  • Redesigned onboarding and settings, What's New per version, "Report a problem" without dictated content.
  • Repository: README rewritten to match the product, release notes generated from the What's New note, issue templates.

Tests: ~150 frontend (vitest), ~470 Rust (cargo test --lib).

🤖 Generated with Claude Code

AmirK-S added 30 commits May 26, 2026 17:26
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.
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).
Amir KELLOU SIDHOUM and others added 27 commits September 15, 2026 19:33
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 AmirK-S changed the title v3.1 polish (WIP): audit response + VAD + audio device picker + frontend tests TTP 3.2.3: reliable long dictations, silent updates, any shortcut Sep 24, 2026
@AmirK-S
AmirK-S marked this pull request as ready for review September 24, 2026 08:06
@AmirK-S
AmirK-S merged commit 1db219c into master Sep 24, 2026
3 checks passed
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