diff --git a/docs/trace-invariants.md b/docs/trace-invariants.md index fe03c8e..7c2c8a1 100644 --- a/docs/trace-invariants.md +++ b/docs/trace-invariants.md @@ -263,9 +263,9 @@ to fire, and what grade that evidence supports. Where a check emits more than one severity, the row says which evidence produces which — that is the difference between a tool that reports rule trips and one that reports harm. -Three checks — `capture-arbiter-left-live`, `keychain-not-single-flighted` -and `writer-newline-lost` — are a different kind from the rest. Every other -check asserts the *absence* of a failure that has happened. Those three assert +Four checks — `capture-arbiter-left-live`, `capture-mic-still-live`, +`keychain-not-single-flighted` and `writer-newline-lost` — are a different kind from the rest. Every other +check asserts the *absence* of a failure that has happened. Those four assert that a fix which has shipped is still engaged, so a hit means a regression rather than a historical scar. They are marked **(regression)** below. @@ -278,6 +278,7 @@ rather than a historical scar. They are marked **(regression)** below. | `capture-handoff-missing` | Every `capture.stop` is followed by a `dictation.start`. **`tracing.md`'s "one shape the trace can only bound, not explain".** | Scoped by *event order*, never by a time window, so a long dictation is never mistaken for an orphan. Graded on how long the capture was held, because that is the only measurement of what was lost: under 0.7 s is a stray tap discarded by a minimum-length guard — an instrumentation gap, WARN; at or above it a real recording vanished with no reason line, ERROR. The 36-hit regrade that set the precedent for this whole document. | | `capture-stop-missing` | Every `capture.start` is followed by a `capture.stop` — or by one of the arbiter's closes (`capture.orphan_prevented`, `capture.orphan_reclaimed`, `capture.stale_dropped`). | Two branches, two grades. Terminated by the **next `capture.start` in the same process**: the stream was demonstrably live across that whole span — ERROR, and this is the 11-hour-microphone shape. Terminated by **`app.launched`**: the process exited, and macOS reclaims a capture device when its owner dies, so the trace cannot say how long the microphone was live — WARN, stating that limit rather than asserting the duration. | | `capture-arbiter-left-live` | **(regression)** When the arbiter refuses or reclaims a stream, the microphone actually goes off. | The Rust drops the cpal stream *before* writing the line, so the line existing means the mic is off. Only three log shapes contradict that, all of them explicit records rather than inferences; each is ERROR. See below. | +| `capture-mic-still-live` | **(regression)** When TTP drops a capture stream, CoreAudio stops running its input. | `capture.mic_still_live` is CoreAudio's answer, not an inference: after 2 s TTP's process still had input running and no newer capture had started. ERROR. It exists because `capture-arbiter-left-live` reads TTP's own bookkeeping and stayed silent through the 2026-09-27 cpal leak. | | `capture-stop-without-start` | `capture.stop` only fires against an open capture. | An explicit `capture.stop_failed` record with its own error string — the app saying so, not the analyser inferring it. WARN: it means bookkeeping disagreed, not that a user lost anything. | | `paste-result-missing` | Every `paste.decision` is followed by a `paste.result`. | Both carry the dictation id. The signature audit item A4 names for a panic under `panic = "abort"`, where `catch_unwind` cannot run. ERROR: the user's text went nowhere and nothing recorded why. In-flight-at-EOF dictations are exempt. | | `paste-verify-missing` | Every successful `paste.result` is followed by a `paste.verify`. | Exact attribution, but the claim is only "landing unproven" — `paste.result` means the events reached the window server, which is not the same as arriving. WARN, because absence of proof is not proof of loss. | diff --git a/docs/tracing.md b/docs/tracing.md index 470d3ba..d8ed077 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -166,13 +166,15 @@ anything from a missing launch line. | `hotkey.tap_rebuilt` | Re-arming stopped helping, so the tap was torn down and recreated. `CGEventTapEnable` on a tap the window server has written off is a no-op — only a fresh tap restores the Fn key. | | `hotkey.stale_fn_cleared` | The Globe key was latched "held" and we forced it down. Keystrokes injected before this were being routed to the Globe shortcut layer. | | `hotkey.timer_stall` | The 20 ms poll timer skipped `gap_ms`. The process was descheduled — nothing advanced during that window: no hotkey, no state machine, no in-flight dictation. TTP now holds an activity assertion for the whole Recording → Idle window (see `crate::activity`), so a stall spanning a dictation should no longer be possible; one that still appears is worth investigating. | -| `capture.start` | Which microphone actually served the recording, its rate/channels/format, whether it is the OS default, and what the user had asked for. `sample_limit` is the callback's backstop for this capture: past that many samples it stops writing, whoever does or does not own the stream. | -| `capture.stop` | Samples the callback delivered, and whether the OS default input changed while the user was talking. `sample_cap_hit:true` means the callback hit `sample_limit` and threw away everything after it. | +| `capture.start` | Which microphone actually served the recording, its rate/channels/format, whether it is the OS default, and what the user had asked for. `route` says why that device: `pinned` (picked in Settings), `pinned_missing` (the pick is not connected, the default stood in), `system_default`, `builtin_over_bluetooth` (the default is a Bluetooth headset, so the Mac's own microphone recorded and the headset stayed out of its call profile), `bluetooth_lid_closed` / `bluetooth_no_builtin` (the headset stayed because the built-in microphone is disconnected or absent). `sample_limit` is the callback's backstop for this capture: past that many samples it stops writing, whoever does or does not own the stream. | +| `capture.stop` | Samples the callback delivered, and whether the OS default input changed while the user was talking (`default_at_start` vs `default_now`; before 2026-09-27 it compared the device opened with the default, so every pinned dictation read `device_changed:true`). `sample_cap_hit:true` means the callback hit `sample_limit` and threw away everything after it. | | `capture.duration_cap` | **The recording reached `limit_secs` (five minutes) and was stopped.** `stopped:false` means there was no recording left to stop; `hands_free` says which mode it was in, `null` when nothing was stopped. Before this line existed the cap sent a fake key release, which hands-free mode ignores, so an unattended hands-free session had no ceiling at all. What was captured is still transcribed. | | `capture.stop_waited_for_start` | The stop path found a start still in flight and waited for it, so the recording is collected rather than orphaned. `ms` is how long it waited; **`timed_out:true` is the interesting one** — it means the 3-second settle window elapsed with the start still unfinished, which the constant's own comment says cannot happen. When it does, `capture_arbiter` is the only thing between the user and a live microphone. | | `capture.orphan_prevented` | **The arbiter refused a start.** A stream had been built, and by the time it asked to go live the state machine no longer wanted one — `reason:"user_idle"` (the press already concluded; this is the 2026-08-30 shape) or `reason:"superseded_by_newer_press"`. The stream is torn down before this line is written, so the line means the microphone is off. `build_ms` is how long the start took to build; the incident's was 544 ms. | | `capture.orphan_reclaimed` | **The Idle backstop closed a live capture nobody was coming to collect.** The reverse ordering: the start published in the gap between a stop that found nothing and the transition to Idle. Carries `samples`, the `device`, `wav_finalised`, `wav_deleted` and `sample_cap_hit`. The unusable WAV is deleted. As with `orphan_prevented`, the stream is dropped before the line is written. | | `capture.stale_dropped` | A new `start_recording` found a capture still in `STATE` from a previous cycle and closed it. `samples` says how much it had written. One of these means an earlier cycle ended with the microphone open and neither the stop nor the backstop caught it. Carries the same `wav_finalised` / `wav_deleted` / `sample_cap_hit` as `orphan_reclaimed`, and the WAV is now deleted. It used to stay in `recordings/`, which is how the 2026-08-30 capture sat there as a 7.2 GB file for eleven days. | +| `capture.mic_released` | **CoreAudio's own answer to "did the microphone go off?"**, written 0.3 s after every stream drop (2 s if the first look still saw input). `site` names the path that dropped it: `stop`, `orphan_prevented`, `orphan_reclaimed`, `stale_dropped`. `released:true` is the normal line. `released:null` means no answer: `reason:"newer_capture"` (a dictation started in between, so input running would be about it), or the OS cannot say (before macOS 14, or not a Mac). Every other capture line reads TTP's bookkeeping; this one reads the HAL. | +| `capture.mic_still_live` | **TTP dropped the stream and CoreAudio still runs its input 2 s later**, with no newer capture to explain it: the orange dot staying on after the user let go. The 2026-09-27 cause was cpal 0.15.3 keeping streams on a mic picked by name alive through a reference cycle, eight clean start/stop pairs over one open microphone. Carries `site`, `device`, `after_ms`. Checked as `capture-mic-still-live`. | | `capture.dead_input_detected` | The microphone has delivered nothing but zeros for `ms` past the grace period, **while the user is still talking**. Emitted once per capture. This is `dead_capture` said at second two instead of at the end: told early, the user loses one sentence and goes to fix their headphones. | | `audio.duration` / `audio.signal` | How much audio, how loud. The silence gate drops a recording only when `rms_after_silence` is below `floor` **and** `speech_ms` (50 ms windows above `speech_window_floor`, itself `max(0.004, 4 × noise_floor)`) is under 300. `window_p50` / `window_p90` give the spread of 50 ms window levels, for calibrating those thresholds on real voices. `rescued_by_speech:true` marks a dictation the old average-only gate would have dropped. `peak` and `nonzero_ratio` distinguish a quiet room from a dead device. | | `audio.convert` | Stereo 48 kHz → mono 16 kHz, and the size change. | @@ -211,6 +213,8 @@ anything from a missing launch line. | `history.saved` | Now also emitted when history is **off**, as `{"skipped":"history_disabled"}`. | | `correction_window.started` | Carries `armed`. It used to be written unconditionally, including on the path that skipped arming. | | `vad.armed` / `vad.fired` / `vad.disarmed` | The auto-stop watchdog: when it started, whether it cut the recording, and whether the stop that ended it was its own or the user's. | +| `hotkey.tap_deaf` | **The tap is enabled and hears nothing.** The hardware's own last-keyboard-event clock (`CGEventSourceSecondsSinceLastEventType`, HID state) is `missed_ms` ahead of the last key event the tap delivered, on two watchdog passes in a row. `rebuilt:true` means a fresh tap was installed (at most every 30 s, never abandoned). Every other health line calls such a tap healthy: on 2026-09-27 no key reached TTP from 16:22 to 16:37 under `tap_health {"enabled":true}`, and only the relaunch of an update brought it back. Not raised while secure input is on, nor within 2 s of TTP's own injected keystrokes. | +| `hotkey.secure_input` | Secure input turned `on` or off, and `owner`, the bundle id holding it. While it is on no tap receives keystrokes — a password field, or an app that forgot to release it — so presses going nowhere have a cause outside TTP. | | `hotkey.tap_health` | **The event tap is alive.** Every five minutes while healthy, and immediately after a recovery. The absence of `hotkey.tap_*` lines used to be ambiguous between "fine" and "not running"; this settles it and bounds any outage to five minutes. | | `permission.helper_shown` / `permission.helper_granted` / `permission.helper_closed` | The drag-to-authorize panel: which permission, whether TTP is running from a bundle at all (`bundle:false` is a dev binary — nothing to drag), whether that bundle is translocated or on a DMG (a grant there does not follow the app), how long the grant took, and why the panel went away. | | `permission.tcc_reset` | **We are about to destroy the user's granted Accessibility permission.** `tccutil reset` is run when a stale-TCC state is detected, and until Polaris it left one `log_warn` and no trace line at all — so a user who was suddenly re-prompted had nothing explaining why. Emitted *before* the command runs, from the one function that runs it, carrying the two probe values that justified the decision (`api_trusted`, `ax_probe_ok`) plus the `bundle_id` and `version`. | @@ -565,6 +569,7 @@ What that means for reading the log: grep -E 'capture\.(orphan_prevented|orphan_reclaimed|stale_dropped)' ttp-trace.log # The guarantee failing +grep 'capture.mic_still_live' ttp-trace.log grep 'capture.reclaim' ttp-trace.log grep 'capture.stop_waited_for_start' ttp-trace.log | grep '"timed_out":true' ``` @@ -629,7 +634,8 @@ replaced bundle. |---|---| | `update.available` | A check found `version` on the stable or `beta` channel. | | `update.downloaded` | The bytes are staged in memory (`bytes`, `ms`). Nothing on disk has changed. | -| `update.download_failed` | The download failed; the next 4 h check retries. | +| `update.download_failed` | The download failed; the next hourly check retries. | +| `update.checked` | An hourly background check from Rust (first one 5 min after launch): `staged` (the version now waiting for a quiet moment), `up_to_date`, or `error`. It replaced a 4 h timer in the hidden webview that App Nap froze, so TTP only ever checked at launch. | | `update.apply` | Install and relaunch, now. `trigger`: `idle` (2 min with no dictation, decided in Rust by `start_idle_applier` — the hidden webview's timers stop under App Nap), `button` (Settings) or `tray`. `staged:false` is a plain relaunch. | | `update.idle_tick_late` | The idle applier's 10 s tick woke more than 5 s late (`late_ms`); that tick is skipped so a wake caused by a key press never relaunches. TTP holds an App Nap assertion while an update is staged, so this should be rare. | | `update.install_failed` | The bundle was not replaced; the staged bytes are kept for the next quiet moment. | diff --git a/package.json b/package.json index 8360289..8a88ce1 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "ttp", "private": true, - "version": "3.2.4", + "version": "3.2.5", "type": "module", "scripts": { "dev": "vite", diff --git a/scripts/trace_analyser/invariants.py b/scripts/trace_analyser/invariants.py index 68cb9a5..6360a8c 100644 --- a/scripts/trace_analyser/invariants.py +++ b/scripts/trace_analyser/invariants.py @@ -95,6 +95,7 @@ "capture.start_failed", "capture.stop_failed", "dictation.rejected", "hotkey.tap_armed", "hotkey.tap_rearmed", "hotkey.tap_rebuilt", "hotkey.tap_abandoned", "hotkey.tap_create_failed", "hotkey.tap_health", + "hotkey.tap_deaf", "hotkey.secure_input", "update.checked", "hotkey.stale_fn_cleared", "hotkey.timer_stall", "capture.start", "capture.stop", "audio.duration", "audio.signal", "audio.rms", "audio.convert", "whisper.request", "whisper.response", "whisper.retry", @@ -119,6 +120,9 @@ "capture.orphan_prevented", "capture.orphan_reclaimed", "capture.stale_dropped", "capture.stop_waited_for_start", "capture.dead_input_detected", + # 2026-09-27: CoreAudio's own answer to "did the microphone go off?", + # written after every stream drop. `capture-mic-still-live` keys off it. + "capture.mic_released", "capture.mic_still_live", "permission.tcc_reset", "permission.tcc_reset_result", "permission.notify", "permission.notify_failed", # Reconciled against the emitting source rather than added one at a time, @@ -835,6 +839,32 @@ def check_arbiter_left_live(corpus: Corpus): ) +@invariant( + "capture-mic-still-live", ERROR, + "When TTP drops a capture stream, CoreAudio stops running its input", + "capture-arbiter-left-live reads TTP's bookkeeping, so it can only say " + "the stream was dropped. On 2026-09-27 that was not the same as the " + "microphone going off: cpal 0.15.3 kept every stream opened on a device " + "picked by name alive through a reference cycle, and eight clean " + "capture.start/capture.stop pairs sat over a microphone that never " + "closed. src-tauri/src/mic_release.rs now asks CoreAudio, 0.3 s and " + "again 2 s after each drop, whether this process still has input " + "running, and writes capture.mic_still_live when it does and no newer " + "capture explains it. Every such line is the orange dot staying on " + "after the user let go.", +) +def check_mic_still_live(corpus: Corpus): + for e in corpus.events: + if e.stage == "capture.mic_still_live": + yield Finding( + "capture-mic-still-live", ERROR, + f"microphone {e.get('device')} still live " + f"{e.get('after_ms')} ms after {e.get('site')} dropped the " + f"stream — it leaked below TTP", + ts=str(e.ts), index=e.index, detail=dict(e.payload), + ) + + @invariant( "paste-result-missing", ERROR, "Every paste.decision is followed by a paste.result", diff --git a/scripts/trace_analyser/tests/fixtures/broken/capture-mic-still-live.log b/scripts/trace_analyser/tests/fixtures/broken/capture-mic-still-live.log new file mode 100644 index 0000000..8be459a --- /dev/null +++ b/scripts/trace_analyser/tests/fixtures/broken/capture-mic-still-live.log @@ -0,0 +1,12 @@ +[2026-09-01 10:00:00.010] [········] app.launched {"arch":"aarch64","os":"macos","version":"3.1.6"} +[2026-09-01 10:00:00.020] [········] hotkey.press {"flags":"0x800000","held_ms":160} +[2026-09-01 10:00:00.021] [········] state.transition {"from":"Idle","to":"Recording"} +[2026-09-01 10:00:00.061] [········] capture.start {"channels":1,"device":"MacBook Air Microphone","format":"F32","is_os_default":true,"preferred":null,"rate":48000} +[2026-09-01 10:00:05.061] [········] hotkey.release {"flags":"0x0"} +[2026-09-01 10:00:05.062] [········] state.transition {"from":"Recording","to":"Processing"} +[2026-09-01 10:00:05.462] [········] capture.stop {"default_now":"MacBook Air Microphone","device":"MacBook Air Microphone","device_changed":false,"samples":240000} +[2026-09-01 10:00:07.462] [········] capture.mic_still_live {"after_ms":2000,"device":"MacBook Air Microphone","site":"stop"} +[2026-09-01 10:00:08.462] [········] hotkey.tap_armed {} +[2026-09-01 10:00:09.462] [········] hotkey.tap_armed {} +[2026-09-01 10:00:10.462] [········] hotkey.tap_armed {} +[2026-09-01 10:00:11.462] [········] hotkey.tap_armed {} diff --git a/scripts/trace_analyser/tests/make_fixtures.py b/scripts/trace_analyser/tests/make_fixtures.py index 66950ec..9f647ff 100644 --- a/scripts/trace_analyser/tests/make_fixtures.py +++ b/scripts/trace_analyser/tests/make_fixtures.py @@ -753,6 +753,16 @@ def new(launched=True) -> Log: tail(log, from_state=None) out["capture-arbiter-left-live"] = log + log = new() + # The 2026-09-27 shape: a clean start/stop pair on a pinned microphone, + # and CoreAudio still running TTP's input two seconds later. + hotkey_cycle(log) + log.free("capture.mic_still_live", + {"after_ms": 2000, "device": "MacBook Air Microphone", + "site": "stop"}, ms=2000) + tail(log, from_state=None) + out["capture-mic-still-live"] = log + log = new() # Eight sequential secret_reads of one account in one session, none of # which waited on another. This is the pre-fix ladder, in milliseconds. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 725c9c7..5677c00 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -207,6 +207,18 @@ dependencies = [ "libc", ] +[[package]] +name = "alsa" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c88dbbce13b232b26250e1e2e6ac18b6a891a646b8148285036ebce260ac5c3" +dependencies = [ + "alsa-sys", + "bitflags 2.11.0", + "cfg-if", + "libc", +] + [[package]] name = "alsa-sys" version = "0.3.1" @@ -986,6 +998,20 @@ dependencies = [ "coreaudio-sys", ] +[[package]] +name = "coreaudio-rs" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aae284fbaf7d27aa0e292f7677dfbe26503b0d555026f702940805a630eac17" +dependencies = [ + "bitflags 1.3.2", + "libc", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", +] + [[package]] name = "coreaudio-sys" version = "0.2.17" @@ -1001,14 +1027,14 @@ version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ - "alsa", + "alsa 0.9.1", "core-foundation-sys", - "coreaudio-rs", + "coreaudio-rs 0.11.3", "dasp_sample", "jni", "js-sys", "libc", - "mach2", + "mach2 0.4.3", "ndk 0.8.0", "ndk-context", "oboe", @@ -1018,6 +1044,36 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpal" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b1f9c7312f19fc2fa12fd7acaf38de54e8320ba10d1a02dcbe21038def51ccb" +dependencies = [ + "alsa 0.10.0", + "coreaudio-rs 0.13.0", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2 0.5.0", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2 0.6.4", + "objc2-audio-toolbox", + "objc2-avf-audio", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.61.3", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -1035,7 +1091,7 @@ checksum = "031ed29858d90cfdf27fe49fae28028a1f20466db97962fa2f4ea34809aeebf3" dependencies = [ "cfg-if", "libc", - "mach2", + "mach2 0.4.3", ] [[package]] @@ -1047,7 +1103,7 @@ dependencies = [ "cfg-if", "crash-context", "libc", - "mach2", + "mach2 0.4.3", "parking_lot", ] @@ -1336,7 +1392,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.61.2", + "windows-sys 0.59.0", ] [[package]] @@ -1565,7 +1621,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -3036,6 +3092,15 @@ dependencies = [ "libc", ] +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3153,7 +3218,7 @@ dependencies = [ "goblin", "libc", "log", - "mach2", + "mach2 0.4.3", "memmap2 0.9.10", "memoffset", "minidump-common", @@ -3446,7 +3511,7 @@ version = "0.7.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff32365de1b6743cb203b710788263c44a03de03802daf96092f2da4fe6ba4d7" dependencies = [ - "proc-macro-crate 3.4.0", + "proc-macro-crate 1.3.1", "proc-macro2", "quote", "syn 2.0.115", @@ -3508,6 +3573,31 @@ dependencies = [ "objc2-quartz-core", ] +[[package]] +name = "objc2-audio-toolbox" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6948501a91121d6399b79abaa33a8aa4ea7857fe019f341b8c23ad6e81b79b08" +dependencies = [ + "bitflags 2.11.0", + "libc", + "objc2 0.6.4", + "objc2-core-audio", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-avf-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13a380031deed8e99db00065c45937da434ca987c034e13b87e4441f9e4090be" +dependencies = [ + "objc2 0.6.4", + "objc2-foundation", +] + [[package]] name = "objc2-cloud-kit" version = "0.3.2" @@ -3519,6 +3609,29 @@ dependencies = [ "objc2-foundation", ] +[[package]] +name = "objc2-core-audio" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e1eebcea8b0dbff5f7c8504f3107c68fc061a3eb44932051c8cf8a68d969c3b2" +dependencies = [ + "dispatch2", + "objc2 0.6.4", + "objc2-core-audio-types", + "objc2-core-foundation", + "objc2-foundation", +] + +[[package]] +name = "objc2-core-audio-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a89f2ec274a0cf4a32642b2991e8b351a404d290da87bb6a9a9d8632490bd1c" +dependencies = [ + "bitflags 2.11.0", + "objc2 0.6.4", +] + [[package]] name = "objc2-core-data" version = "0.3.2" @@ -3537,7 +3650,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ "bitflags 2.11.0", + "block2 0.6.2", "dispatch2", + "libc", "objc2 0.6.4", ] @@ -3863,7 +3978,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] @@ -4783,7 +4898,7 @@ version = "0.19.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6006a627c1a38d37f3d3a85c6575418cfe34a5392d60a686d0071e1c8d427acb" dependencies = [ - "cpal", + "cpal 0.15.3", "hound", "thiserror 1.0.69", ] @@ -4819,7 +4934,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -4876,7 +4991,7 @@ dependencies = [ "security-framework 3.6.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6202,7 +6317,7 @@ dependencies = [ "getrandom 0.4.1", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6611,14 +6726,14 @@ checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" [[package]] name = "ttp" -version = "3.2.4" +version = "3.2.5" dependencies = [ "block", "chrono", "cocoa", "core-foundation 0.10.1", "core-graphics 0.24.0", - "cpal", + "cpal 0.17.1", "dirs 5.0.1", "enigo", "getrandom 0.3.4", @@ -7269,7 +7384,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.48.0", ] [[package]] diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c6ecedc..8725660 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "ttp" -version = "3.2.4" +version = "3.2.5" description = "Talk To Paste - Voice to text transcription" authors = ["TTP Team"] edition = "2021" @@ -23,7 +23,7 @@ serde = { version = "1", features = ["derive"] } serde_json = "1" rodio = { version = "0.19", default-features = false, features = ["wav"] } hound = "3.5" -cpal = "0.15" +cpal = "0.17" tauri-plugin-http = "2" reqwest = { version = "0.12", default-features = false, features = ["multipart", "json", "rustls-tls"] } chrono = "0.4" diff --git a/src-tauri/src/audio_capture.rs b/src-tauri/src/audio_capture.rs index 0d9b38e..4da87c8 100644 --- a/src-tauri/src/audio_capture.rs +++ b/src-tauri/src/audio_capture.rs @@ -88,14 +88,14 @@ fn enumerate_input_devices() -> Result, String> { let host = cpal::default_host(); let default_name = host .default_input_device() - .and_then(|d| d.name().ok()); + .and_then(|d| device_name(&d)); let mut out = Vec::new(); let devices = host .input_devices() .map_err(|e| format!("Failed to list input devices: {}", e))?; for device in devices { - if let Ok(name) = device.name() { + if let Some(name) = device_name(&device) { let is_default = default_name.as_deref() == Some(name.as_str()); out.push(AudioInputDeviceInfo { name, is_default }); } @@ -103,34 +103,65 @@ fn enumerate_input_devices() -> Result, String> { Ok(out) } -/// Resolve the cpal input device to record from. When `preferred_name` is -/// Some, walk the device list and match by name. Falls back to the system -/// default if not found (logged as info — common on hot-unplug events). +/// Resolve the cpal input device to record from, and say why. +/// +/// A device the user picked in Settings wins, matched by name; when it is not +/// connected (AirPods put away since) the default stands in. With no pick, +/// `input_route` decides: the OS default, unless that is a Bluetooth headset +/// and the Mac has a microphone of its own — see that module for why. fn resolve_input_device( host: &cpal::Host, preferred_name: &Option, -) -> Result { - if let Some(name) = preferred_name.as_deref().filter(|s| !s.is_empty()) { - if let Ok(devices) = host.input_devices() { - for device in devices { - // cpal's `Device::name()` returns `Result`; - // unwrap the success arm explicitly and compare strings rather - // than relying on Result comparison (which fails to compile - // because the error arms aren't PartialEq-compatible). - if device.name().ok().as_deref() == Some(name) { - return Ok(device); - } - } +) -> Result<(cpal::Device, crate::input_route::Route), String> { + use crate::input_route::Route; + let pinned = preferred_name.as_deref().filter(|s| !s.is_empty()); + let route = if let Some(name) = pinned { + if let Some(device) = input_device_named(host, name) { + return Ok((device, Route::Pinned)); } log_info(&format!( "[AudioCapture] preferred input device '{}' not found, falling back to default", name )); - } + Route::PinnedMissing + } else { + let scene = crate::input_route::scene(); + match crate::input_route::choose(&scene) { + (route, Some(name)) => match input_device_named(host, name) { + Some(device) => return Ok((device, route)), + None => { + // CoreAudio named a built-in microphone cpal cannot find. + // Recording from the headset is the old behaviour, so say + // so and carry on. + crate::trace::degraded( + "capture.input_route", + serde_json::json!({ "wanted": name, "error": "not in cpal's device list" }), + ); + Route::SystemDefault + } + }, + (route, None) => route, + } + }; host.default_input_device() + .map(|d| (d, route)) .ok_or_else(|| "No default input device available".to_string()) } +/// The device's name as CoreAudio reports it — the key Settings persists. +/// +/// cpal 0.17 deprecated `name()` for `description()`; the name inside is the +/// same string, so a device saved under 0.15 still matches. +fn device_name(device: &cpal::Device) -> Option { + device.description().ok().map(|d| d.name().to_string()) +} + +fn input_device_named(host: &cpal::Host, name: &str) -> Option { + host.input_devices() + .ok()? + .find(|device| device_name(device).as_deref() == Some(name)) +} + /// Name of the input device that served the most recent recording. /// /// The transcription pipeline reads this when a capture comes back as @@ -141,6 +172,10 @@ fn resolve_input_device( /// as an identical `dead_capture` line and the user is back to guessing. static LAST_CAPTURE_DEVICE: Mutex> = Mutex::new(None); +/// The OS-default input when the most recent recording started, so the stop +/// can tell whether it moved while the user was talking. +static DEFAULT_AT_START: Mutex> = Mutex::new(None); + /// The input device used for the most recent recording, if one has run. pub fn last_capture_device() -> Option { LAST_CAPTURE_DEVICE.lock().ok().and_then(|g| g.clone()) @@ -154,7 +189,7 @@ pub fn last_capture_device() -> Option { fn current_default_input_name() -> Option { cpal::default_host() .default_input_device() - .and_then(|d| d.name().ok()) + .and_then(|d| device_name(&d)) } type WavWriterHandle = Arc>>>>; @@ -549,7 +584,7 @@ async fn start_recording_inner(app: AppHandle) -> Result<( )); // Read before this start re-arms it for the new capture. let sample_cap_hit = SAMPLE_CAP_HIT.load(Ordering::SeqCst); - let discarded = close_and_discard(stale); + let discarded = close_and_discard(stale, "stale_dropped"); // A stale capture reaching here means a previous cycle ended with // the microphone still open. It used to leave a `log_warn` in a // file that is filtered to Warn in release and read by nobody. @@ -592,7 +627,7 @@ async fn start_recording_inner(app: AppHandle) -> Result<( // device would surface as a generic "no device available" error // that the user wouldn't know how to fix. let host = cpal::default_host(); - let device = resolve_input_device(&host, &crate::settings::get_settings().audio_device_name)?; + let (device, route) = resolve_input_device(&host, &crate::settings::get_settings().audio_device_name)?; let supported_config = device .default_input_config() .map_err(|e| format!("No default input config: {}", e))?; @@ -606,14 +641,14 @@ async fn start_recording_inner(app: AppHandle) -> Result<( .map_err(|e| format!("Failed to create WAV writer: {}", e))?, ))); - let device_name = device.name().unwrap_or_else(|_| "".into()); + let device_name = device_name(&device).unwrap_or_else(|| "".into()); let preferred = crate::settings::get_settings().audio_device_name; let default_name = current_default_input_name(); log_info(&format!( "[AudioCapture] starting: device={:?} rate={} ch={} fmt={:?} → {}", device_name, - config.sample_rate().0, + config.sample_rate(), config.channels(), config.sample_format(), save_path.display() @@ -622,8 +657,11 @@ async fn start_recording_inner(app: AppHandle) -> Result<( if let Ok(mut slot) = LAST_CAPTURE_DEVICE.lock() { *slot = Some(device_name.clone()); } + if let Ok(mut slot) = DEFAULT_AT_START.lock() { + *slot = default_name.clone(); + } - let sample_limit = sample_limit_for(config.sample_rate().0, config.channels()); + let sample_limit = sample_limit_for(config.sample_rate(), config.channels()); crate::trace::event( "capture.start", @@ -631,7 +669,10 @@ async fn start_recording_inner(app: AppHandle) -> Result<( "device": device_name, "is_os_default": default_name.as_deref() == Some(device_name.as_str()), "preferred": preferred, - "rate": config.sample_rate().0, + // Why this device: pinned, the OS default, or the Mac's own + // microphone standing in for a Bluetooth headset. + "route": route.slug(), + "rate": config.sample_rate(), "channels": config.channels(), "format": format!("{:?}", config.sample_format()), "sample_limit": sample_limit, @@ -648,12 +689,13 @@ async fn start_recording_inner(app: AppHandle) -> Result<( // Armed before the stream exists, so the callback never runs uncapped. SAMPLE_LIMIT.store(sample_limit, Ordering::SeqCst); SAMPLE_CAP_HIT.store(false, Ordering::SeqCst); + crate::mic_release::note_stream_built(); let stream = build_stream(&device, &supported_config, &writer_handle, &samples_written, &app)?; stream .play() .map_err(|e| format!("Failed to start audio stream: {}", e))?; let started_at = std::time::Instant::now(); - let samples_per_sec = config.sample_rate().0 as u64 * config.channels() as u64; + let samples_per_sec = config.sample_rate() as u64 * config.channels() as u64; // 6. Ask permission, then stash everything in shared state for // stop_recording to pick up. @@ -670,6 +712,7 @@ async fn start_recording_inner(app: AppHandle) -> Result<( // this is the microphone going off, and it must happen before we // return. drop(stream); + crate::mic_release::verify_after_drop("orphan_prevented"); reset_rms(); disarm_dead_input_watch(); if let Ok(mut w) = writer_handle.lock() { @@ -807,7 +850,7 @@ pub fn reclaim_orphaned_capture() { }; drop(guard); - let discarded = close_and_discard(orphan); + let discarded = close_and_discard(orphan, "orphan_reclaimed"); ARBITER.mark_reclaimed(); log_warn(&format!( @@ -840,11 +883,12 @@ struct Discarded { /// Shared by the two paths that find one — the Idle backstop and a start that /// finds a stale capture still in STATE — so they cannot drift apart again. /// They had: this one deleted its WAV, the stale path left its WAV on disk. -fn close_and_discard(capture: RecordingState) -> Discarded { +fn close_and_discard(capture: RecordingState, site: &'static str) -> Discarded { let samples = capture.samples_written.load(Ordering::SeqCst); // Dropping the stream closes the cpal callback. This is the line that // turns the microphone off. drop(capture.stream); + crate::mic_release::verify_after_drop(site); reset_rms(); disarm_dead_input_watch(); let finalised = match capture.writer.lock() { @@ -981,6 +1025,7 @@ async fn stop_recording_inner() -> Result { // briefly) before this drop completes; that's the correct behaviour — // we want every sample the device gave us. drop(state.stream); + crate::mic_release::verify_after_drop("stop"); // The pill subscribes to RMS via current_rms(); reset it now so it // doesn't briefly render the last captured frame after the stream ends. reset_rms(); @@ -1007,15 +1052,21 @@ async fn stop_recording_inner() -> Result { state.save_path.display() )); - // Re-read the OS default now. If it no longer matches the device we - // opened, something moved the default while the user was talking — a - // Bluetooth headset connecting, or going to sleep and handing input back - // to the built-in mic. That switch is invisible to an already-open cpal - // stream, which keeps happily delivering buffers from a device that has - // stopped producing audio. + // Re-read the OS default now. If it no longer matches the default at + // start, something moved it while the user was talking — a Bluetooth + // headset connecting, or going to sleep and handing input back to the + // built-in mic. That switch is invisible to an already-open cpal stream, + // which keeps happily delivering buffers from a device that has stopped + // producing audio. + // + // Compared with the default at start, not with the device opened: those + // differ on purpose whenever the user pinned a device or the Mac's own + // microphone stood in for a headset, and comparing them reported + // `device_changed:true` on every such dictation. let opened = last_capture_device(); + let default_at_start = DEFAULT_AT_START.lock().ok().and_then(|g| g.clone()); let default_now = current_default_input_name(); - let device_changed = match (&opened, &default_now) { + let device_changed = match (&default_at_start, &default_now) { (Some(a), Some(b)) => a != b, _ => false, }; @@ -1024,6 +1075,7 @@ async fn stop_recording_inner() -> Result { "capture.stop", serde_json::json!({ "device": opened, + "default_at_start": default_at_start, "default_now": default_now, "device_changed": device_changed, // Samples the callback actually delivered. Zero means the stream @@ -1216,7 +1268,7 @@ fn wav_spec_from_config(config: &cpal::SupportedStreamConfig) -> WavSpec { }; WavSpec { channels: config.channels(), - sample_rate: config.sample_rate().0, + sample_rate: config.sample_rate(), bits_per_sample: (config.sample_format().sample_size() * 8) as u16, sample_format, } diff --git a/src-tauri/src/dictionary/classify.rs b/src-tauri/src/dictionary/classify.rs index 392bd44..6e56389 100644 --- a/src-tauri/src/dictionary/classify.rs +++ b/src-tauri/src/dictionary/classify.rs @@ -4,6 +4,9 @@ // Before adding a correction to the dictionary, asks the LLM to classify it // as LEARN (proper nouns, brands, technical terms) or IGNORE (grammar, style). +use crate::http_client::{retry_guidance, shared as shared_http}; +use crate::logging::{log_error, log_warn}; +use crate::transcription::polish::{MODEL, REASONING_EFFORT, REASONING_TOKEN_HEADROOM}; use serde::{Deserialize, Serialize}; use std::time::Duration; @@ -13,6 +16,17 @@ const CHAT_URL: &str = "https://api.groq.com/openai/v1/chat/completions"; /// Request timeout in seconds (short — this is non-critical) const REQUEST_TIMEOUT_SECS: u64 = 10; +/// Tokens for the visible answer: one word, LEARN or IGNORE. +/// +/// This was the whole `max_tokens: 16` budget when the gate ran on +/// `llama-3.3-70b-versatile`. On gpt-oss (see [`MODEL`]) reasoning tokens +/// are spent first and count against the same cap, so 16 alone would be +/// eaten before the verdict is written: `content` comes back empty, the old +/// `contains("LEARN")` read that as IGNORE, and the dictionary would have +/// gone on learning nothing — silently, just by a different mechanism than +/// the 404. The reasoning headroom polish uses is added on top. +const VERDICT_TOKENS: u32 = 16; + /// System prompt for correction classification const CLASSIFY_SYSTEM_PROMPT: &str = r#"You classify corrections from a speech-to-text app. Given an original transcribed word and the user's correction, respond with EXACTLY one word: LEARN or IGNORE. @@ -31,13 +45,20 @@ Examples: "bonjour" → "Bonjour" → IGNORE (capitalization) "les" → "des" → IGNORE (article swap)"#; -/// Chat completion request body +/// Chat completion request body. +/// +/// Same shape as polish's request minus `response_format`: the gate asks for +/// a bare word, not JSON, so the reply format it parses is unchanged by the +/// model migration. #[derive(Debug, Serialize)] struct ChatRequest { - model: String, + model: &'static str, messages: Vec, temperature: f32, - max_tokens: u32, + /// `max_tokens` is deprecated on OpenAI-compatible endpoints and, on + /// reasoning models, does not reliably account for reasoning tokens. + max_completion_tokens: u32, + reasoning_effort: &'static str, } /// Chat message structure @@ -59,10 +80,139 @@ struct ChatChoice { message: ChatMessageResponse, } -/// Message content in chat response +/// Message content in chat response. +/// +/// Optional: a reasoning model that runs out of budget mid-thought can return +/// `"content": null` (its reasoning lives in a separate field). That must be +/// a reported failure, not a deserialization error that hides the cause. #[derive(Debug, Deserialize)] struct ChatMessageResponse { - content: String, + #[serde(default)] + content: Option, +} + +/// Build the request body. Pure, so the model and budget it sends are +/// unit-tested without a network call. +fn build_request(original: &str, correction: &str, context_sentence: &str) -> ChatRequest { + let user_content = format!( + "Original: \"{}\"\nCorrection: \"{}\"\nContext: \"{}\"", + original, correction, context_sentence + ); + ChatRequest { + model: MODEL, + messages: vec![ + ChatMessage { + role: "system".to_string(), + content: CLASSIFY_SYSTEM_PROMPT.to_string(), + }, + ChatMessage { + role: "user".to_string(), + content: user_content, + }, + ], + temperature: 0.0, + max_completion_tokens: VERDICT_TOKENS + REASONING_TOKEN_HEADROOM, + reasoning_effort: REASONING_EFFORT, + } +} + +/// Read the verdict out of the model's reply: `Some(true)` for LEARN, +/// `Some(false)` for IGNORE, `None` if it names neither. +/// +/// Whichever keyword appears first wins, so "LEARN (brand name)" and +/// "IGNORE." both read correctly, and so does a reply that explains itself +/// ("IGNORE — not a LEARN case"), which the old `contains("LEARN")` read +/// backwards. A reply naming neither is an error, not an IGNORE: an empty or +/// garbled answer is exactly how a broken model looks, and folding it into +/// IGNORE is how the gate went quiet. +fn parse_verdict(content: &str) -> Option { + let upper = content.to_uppercase(); + match (upper.find("LEARN"), upper.find("IGNORE")) { + (Some(l), Some(i)) => Some(l < i), + (Some(_), None) => Some(true), + (None, Some(_)) => Some(false), + (None, None) => None, + } +} + +/// Parse a successful chat-completions body into a verdict. +fn parse_response_body(body: &str) -> Result { + let chat_response: ChatResponse = serde_json::from_str(body) + .map_err(|e| format!("Failed to parse classify response: {}", e))?; + + let content = chat_response + .choices + .into_iter() + .next() + .ok_or_else(|| "Empty response from classify API".to_string())? + .message + .content + .unwrap_or_default(); + + parse_verdict(&content).ok_or_else(|| { + // Length only: the reply may quote the user's dictation back. + format!( + "Classify reply named neither LEARN nor IGNORE (model={}, reply_len={})", + MODEL, + content.chars().count() + ) + }) +} + +/// How loudly to report a failed classify call. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum Severity { + /// Transient; the next correction will likely go through. + Warn, + /// Will fail the same way on every correction until someone acts. + Error, +} + +/// Describe a non-2xx reply. Pure — the policy is unit-tested as such. +/// +/// The message keeps the status code verbatim and names the model: when the +/// gate stops learning, "which model were we asking for" is the first +/// question, and a name the provider has retired is the usual answer. +fn describe_http_failure(status: u16, body: &str, retry_after_ms: Option) -> (Severity, String) { + match status { + 404 => ( + Severity::Error, + format!( + "[Classify] Model `{}` not found (404) — the provider has likely retired it. \ + Dictionary auto-learning is OFF until `polish::MODEL` is updated. Body: {}", + MODEL, body + ), + ), + 401 | 403 => ( + Severity::Error, + format!( + "[Classify] Groq rejected the API key ({}); dictionary auto-learning is off. Body: {}", + status, body + ), + ), + // Single attempt by design (see below): a rate limit costs this one + // correction, not the feature, so it is a warning. The server's own + // delay goes in the line so a burst is readable from the log. + 429 => ( + Severity::Warn, + format!( + "[Classify] Rate limited (429, model={}, retry_after_ms={}); correction not learned", + MODEL, + retry_after_ms.map_or_else(|| "none".to_string(), |ms| ms.to_string()) + ), + ), + _ => ( + Severity::Error, + format!("[Classify] API error {} (model={}): {}", status, MODEL, body), + ), + } +} + +fn log_at(severity: Severity, message: &str) { + match severity { + Severity::Warn => log_warn(message), + Severity::Error => log_error(message), + } } /// Classify a correction as LEARN (true) or IGNORE (false) using the LLM. @@ -71,8 +221,12 @@ struct ChatMessageResponse { /// added to the dictionary. Returns `true` for proper nouns, brands, technical /// terms, and accent fixes. Returns `false` for grammar, style, and common words. /// -/// Fails closed: if the LLM call fails for any reason, returns `false` -/// (do not add to dictionary). +/// Fails closed: if the LLM call fails for any reason, returns `Err` and the +/// caller does not add to the dictionary. Every failure is logged here first +/// (error for what will recur on every call — retired model, bad key, server +/// error, unreadable reply; warn for what is transient — rate limit, network), +/// because a gate that fails closed in silence looks exactly like a gate that +/// is working and finding nothing to learn. /// /// # Arguments /// * `api_key` - Groq API key @@ -85,60 +239,137 @@ pub async fn classify_correction( correction: &str, context_sentence: &str, ) -> Result { - let user_content = format!( - "Original: \"{}\"\nCorrection: \"{}\"\nContext: \"{}\"", - original, correction, context_sentence - ); + let request_body = build_request(original, correction, context_sentence); - let client = reqwest::Client::builder() - .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) - .build() - .map_err(|e| format!("Failed to create HTTP client: {}", e))?; - - let request_body = ChatRequest { - model: "llama-3.3-70b-versatile".to_string(), - messages: vec![ - ChatMessage { - role: "system".to_string(), - content: CLASSIFY_SYSTEM_PROMPT.to_string(), - }, - ChatMessage { - role: "user".to_string(), - content: user_content, - }, - ], - temperature: 0.0, - max_tokens: 16, - }; - - // Single attempt, no retries — this is non-critical - let response = client + // Single attempt, no retries — this is non-critical. Unlike polish, no + // one is waiting on this call, but a retry loop here would compete with + // polish for the same free-tier TPM budget on the same model. + let response = match shared_http() .post(CHAT_URL) + .timeout(Duration::from_secs(REQUEST_TIMEOUT_SECS)) .header("Authorization", format!("Bearer {}", api_key)) .header("Content-Type", "application/json") .json(&request_body) .send() .await - .map_err(|e| format!("Classify request failed: {}", e))?; + { + Ok(r) => r, + Err(e) => { + let message = format!( + "[Classify] Request failed (model={}, timed_out={}): {}", + MODEL, + e.is_timeout(), + e + ); + log_warn(&message); + return Err(message); + } + }; let status = response.status(); + // Headers before the body: `text()` consumes the response, and + // `retry-after` lives in the headers. + let headers = response.headers().clone(); + let body = response.text().await.unwrap_or_default(); + if !status.is_success() { - let error_body = response.text().await.unwrap_or_default(); - return Err(format!("Classify API error: {} - {}", status, error_body)); + let retry_after_ms = retry_guidance(&headers, &body).map(|g| g.delay_ms); + let (severity, message) = describe_http_failure(status.as_u16(), &body, retry_after_ms); + log_at(severity, &message); + return Err(message); } - let chat_response: ChatResponse = response - .json() - .await - .map_err(|e| format!("Failed to parse classify response: {}", e))?; + parse_response_body(&body).inspect_err(|e| log_error(&format!("[Classify] {}", e))) +} - let content = chat_response - .choices - .into_iter() - .next() - .map(|choice| choice.message.content) - .ok_or_else(|| "Empty response from classify API".to_string())?; +#[cfg(test)] +mod tests { + use super::*; + + /// The id Groq retired on 2026-08-16. Every classify call 404'd on it and + /// the gate learned nothing, with no sign of it outside ttp.log. + const RETIRED_MODEL: &str = "llama-3.3-70b-versatile"; + + fn request_json() -> serde_json::Value { + serde_json::to_value(build_request("Grok", "Groq", "I use Grok daily")).unwrap() + } + + #[test] + fn classify_asks_for_the_same_model_as_polish() { + // One model id for the crate: when it has to change again, it changes + // in `polish::MODEL` and the gate follows instead of 404ing alone. + let body = request_json(); + assert_eq!(body["model"], MODEL); + assert_eq!(body["model"], "openai/gpt-oss-120b"); + assert_ne!(body["model"], RETIRED_MODEL); + } + + #[test] + fn the_verdict_budget_survives_reasoning() { + let body = request_json(); + assert_eq!(body["reasoning_effort"], REASONING_EFFORT); + let cap = body["max_completion_tokens"].as_u64().unwrap(); + assert!(cap > u64::from(REASONING_TOKEN_HEADROOM), "cap {cap} leaves no room for the verdict"); + // The deprecated field must not be sent alongside the modern one. + assert!(body.get("max_tokens").is_none()); + } - let trimmed = content.trim().to_uppercase(); - Ok(trimmed.contains("LEARN")) + #[test] + fn the_gate_still_asks_for_a_bare_word_not_json() { + // The verdict parser reads text; a json_object response_format would + // force the model to wrap it. + assert!(request_json().get("response_format").is_none()); + } + + #[test] + fn verdict_parsing() { + assert_eq!(parse_verdict("LEARN"), Some(true)); + assert_eq!(parse_verdict(" learn\n"), Some(true)); + assert_eq!(parse_verdict("LEARN (brand name)"), Some(true)); + assert_eq!(parse_verdict("IGNORE"), Some(false)); + assert_eq!(parse_verdict("Ignore."), Some(false)); + // First keyword wins — the old `contains("LEARN")` said true here. + assert_eq!(parse_verdict("IGNORE — not a LEARN case"), Some(false)); + assert_eq!(parse_verdict(""), None); + assert_eq!(parse_verdict("maybe"), None); + } + + #[test] + fn a_gpt_oss_reply_parses() { + // Shape of a Groq gpt-oss completion: reasoning in its own field, + // the answer alone in `content`. + let body = r#"{"id":"chatcmpl-x","object":"chat.completion","model":"openai/gpt-oss-120b", + "choices":[{"index":0,"message":{"role":"assistant","reasoning":"Groq is a company name.","content":"LEARN"},"finish_reason":"stop"}]}"#; + assert_eq!(parse_response_body(body), Ok(true)); + let body = body.replace("\"content\":\"LEARN\"", "\"content\":\"IGNORE\""); + assert_eq!(parse_response_body(&body), Ok(false)); + } + + #[test] + fn a_reply_truncated_by_reasoning_is_an_error_not_an_ignore() { + let body = r#"{"choices":[{"index":0,"message":{"role":"assistant","reasoning":"Hmm, the user","content":null},"finish_reason":"length"}]}"#; + let err = parse_response_body(body).unwrap_err(); + assert!(err.contains("neither LEARN nor IGNORE"), "{err}"); + assert!(parse_response_body(r#"{"choices":[]}"#).is_err()); + assert!(parse_response_body("not json").is_err()); + } + + #[test] + fn a_retired_model_is_reported_as_an_error_that_names_it() { + let body = r#"{"error":{"message":"The model `llama-3.3-70b-versatile` does not exist or you do not have access to it.","type":"invalid_request_error","code":"model_not_found"}}"#; + let (severity, message) = describe_http_failure(404, body, None); + assert_eq!(severity, Severity::Error); + assert!(message.contains("404")); + assert!(message.contains(MODEL)); + assert!(message.contains("model_not_found")); + } + + #[test] + fn failure_severity_policy() { + assert_eq!(describe_http_failure(401, "", None).0, Severity::Error); + assert_eq!(describe_http_failure(500, "", None).0, Severity::Error); + let (severity, message) = describe_http_failure(429, "", Some(6352)); + assert_eq!(severity, Severity::Warn); + assert!(message.contains("retry_after_ms=6352"), "{message}"); + } } diff --git a/src-tauri/src/fnkey.rs b/src-tauri/src/fnkey.rs index 68f864a..4f4b6ae 100644 --- a/src-tauri/src/fnkey.rs +++ b/src-tauri/src/fnkey.rs @@ -22,7 +22,8 @@ // and to filter out brief F-key presses. use crate::fnkey_fsm::{ - fn_decide, fn_stale_check, FnAction, FnFsmState, FN_DEBOUNCE_MS, FN_STALE_RESYNC_TICKS, + deaf_check, fn_decide, fn_stale_check, FnAction, FnFsmState, FN_DEBOUNCE_MS, + FN_STALE_RESYNC_TICKS, }; // DOUBLE_TAP_THRESHOLD_MS / HANDS_FREE_STOP_GRACE_MS are referenced via the // FSM module's internal logic; we don't need them here. FN_DEBOUNCE_MS is @@ -104,9 +105,16 @@ extern "C" { buf: *mut u16, ); fn CFRunLoopRun(); + fn CGEventSourceSecondsSinceLastEventType(state: i32, event_type: u32) -> f64; + fn CGSessionCopyCurrentDictionary() -> *const std::ffi::c_void; static kCFRunLoopCommonModes: CFStringRef; } +#[link(name = "Carbon", kind = "framework")] +extern "C" { + fn IsSecureEventInputEnabled() -> u8; +} + const KCG_HID_EVENT_TAP: u32 = 0; const KCG_TAIL_APPEND_EVENT_TAP: u32 = 1; const KCG_EVENT_TAP_OPTION_LISTEN_ONLY: u32 = 1; @@ -295,6 +303,19 @@ const TAP_REBUILD_AFTER_FAILED_REARMS: u64 = 5; /// interval, carrying the streak count so the flapping is still visible. const REARM_LOG_INTERVAL_MS: u64 = 30_000; +/// Wall-clock ms of the last keyboard event the tap delivered — or of the +/// tap's installation, so a key typed before it existed is not "missed". +static LAST_TAP_KEY_EVENT_MS: AtomicU64 = AtomicU64::new(0); +/// The previous watchdog pass suspected the tap of missing a key. +static DEAF_SUSPECT: AtomicBool = AtomicBool::new(false); +/// When a deaf tap was last rebuilt, to space rebuilds out. +static LAST_DEAF_REBUILD_MS: AtomicU64 = AtomicU64::new(0); +/// Rebuilding a deaf tap at most this often. Unlike the re-arm ladder this +/// never gives up: a deaf tap loses every press, and a rebuild is cheap. +const DEAF_REBUILD_MIN_GAP_MS: u64 = 30_000; +/// Whether secure input was on at the previous watchdog pass. +static SECURE_INPUT_ON: AtomicBool = AtomicBool::new(false); + /// Wall-clock time of the previous timer tick, for stall detection. static LAST_TICK_MS: AtomicU64 = AtomicU64::new(0); @@ -503,6 +524,8 @@ unsafe fn install_tap(reason: &str) -> bool { TAP_PORT.store(tap as *mut std::ffi::c_void, Ordering::Relaxed); TAP_SOURCE.store(source as *mut std::ffi::c_void, Ordering::Relaxed); REARM_STREAK.store(0, Ordering::Relaxed); + LAST_TAP_KEY_EVENT_MS.store(now_ms(), Ordering::Relaxed); + DEAF_SUSPECT.store(false, Ordering::Relaxed); fnlog!("[FnKey] CGEventTap armed at HID level ({})", reason); crate::trace::event( @@ -612,6 +635,100 @@ fn re_arm_tap(reason: &str) { ); } +/// Wall-clock ms of the last keyboard event the hardware produced, read from +/// the HID system state — a clock no tap can go deaf to. +unsafe fn hid_last_key_event_ms(now: u64) -> u64 { + let age = [KCG_EVENT_KEY_DOWN, KCG_EVENT_KEY_UP, KCG_EVENT_FLAGS_CHANGED] + .iter() + .map(|&t| CGEventSourceSecondsSinceLastEventType(KCG_EVENT_SOURCE_STATE_HID, t)) + .filter(|a| a.is_finite() && *a >= 0.0) + .fold(f64::INFINITY, f64::min); + if !age.is_finite() { + return 0; + } + now.saturating_sub((age * 1000.0) as u64) +} + +/// Bundle id of the app holding secure input, when macOS says which. +unsafe fn secure_input_owner() -> Option { + use core_foundation::base::TCFType; + use core_foundation::dictionary::CFDictionary; + use core_foundation::number::CFNumber; + use core_foundation::string::CFString; + let raw = CGSessionCopyCurrentDictionary(); + if raw.is_null() { + return None; + } + let dict: CFDictionary = + CFDictionary::wrap_under_create_rule(raw as _); + let pid = dict + .find(CFString::from_static_string("kCGSSessionSecureInputPID"))? + .downcast::()? + .to_i32()?; + let app: id = msg_send![class!(NSRunningApplication), runningApplicationWithProcessIdentifier: pid]; + if app.is_null() { + return Some(format!("pid {}", pid)); + } + let bundle: id = msg_send![app, bundleIdentifier]; + if bundle.is_null() { + return Some(format!("pid {}", pid)); + } + let utf8: *const std::ffi::c_char = msg_send![bundle, UTF8String]; + Some(std::ffi::CStr::from_ptr(utf8).to_string_lossy().into_owned()) +} + +/// One watchdog pass of the deaf-tap check. See `fnkey_fsm::deaf_check`. +/// +/// Also says when secure input turns on or off, and who turned it on: while +/// it is on no tap receives keys, so "TTP ignored my key" has a second, +/// entirely different cause that must be told apart from a deaf tap. +unsafe fn check_for_deaf_tap(now: u64) { + let secure = IsSecureEventInputEnabled() != 0; + if SECURE_INPUT_ON.swap(secure, Ordering::Relaxed) != secure { + crate::trace::event( + "hotkey.secure_input", + serde_json::json!({ "on": secure, "owner": if secure { secure_input_owner() } else { None } }), + ); + } + + let hid_last = hid_last_key_event_ms(now); + let tap_last = LAST_TAP_KEY_EVENT_MS.load(Ordering::Relaxed); + let verdict = deaf_check( + hid_last, + tap_last, + crate::paste::input_marks::last_injection_ms(), + now, + DEAF_SUSPECT.load(Ordering::Relaxed), + secure, + ); + DEAF_SUSPECT.store(verdict.suspect && !verdict.deaf, Ordering::Relaxed); + if !verdict.deaf { + return; + } + + let since_rebuild = now.saturating_sub(LAST_DEAF_REBUILD_MS.load(Ordering::Relaxed)); + let rebuild = since_rebuild >= DEAF_REBUILD_MIN_GAP_MS; + crate::logging::log_warn(&format!( + "[FnKey] Event tap is enabled but deaf: the keyboard produced an event {} ms after \ + the last one the tap delivered.{}", + verdict.missed_ms, + if rebuild { " Rebuilding it." } else { " Rebuilt recently; waiting." } + )); + crate::trace::event( + "hotkey.tap_deaf", + serde_json::json!({ + "missed_ms": verdict.missed_ms, + "hid_age_ms": now.saturating_sub(hid_last), + "rebuilt": rebuild, + }), + ); + if rebuild { + LAST_DEAF_REBUILD_MS.store(now, Ordering::Relaxed); + teardown_tap(); + install_tap("deaf"); + } +} + /// Start Fn key monitoring using NSTimer on the main run loop. /// Must be called from the main thread (during app setup). pub fn start_fn_key_monitor(app: &AppHandle) { @@ -650,9 +767,11 @@ pub fn start_fn_key_monitor(app: &AppHandle) { // to do nothing interesting. let tick_now = now_ms(); let prev_tick = LAST_TICK_MS.swap(tick_now, Ordering::Relaxed); + let mut stalled = false; if prev_tick != 0 { let gap_ms = tick_now.saturating_sub(prev_tick); if gap_ms >= TIMER_STALL_THRESHOLD_MS { + stalled = true; crate::trace::event( "hotkey.timer_stall", serde_json::json!({ "gap_ms": gap_ms }), @@ -717,6 +836,11 @@ pub fn start_fn_key_monitor(app: &AppHandle) { }), ); } + // Enabled is not the same as hearing. A stalled main + // thread holds its events in the queue, so skip that pass. + if !stalled { + check_for_deaf_tap(tick_now); + } } else if REARM_STREAK.load(Ordering::Relaxed) >= TAP_REBUILD_AFTER_FAILED_REARMS { // Re-enabling has demonstrably stopped working. Stop // asking and build a new tap. @@ -910,6 +1034,10 @@ unsafe extern "C" fn fkey_tap_callback( return event; } + if matches!(event_type, KCG_EVENT_KEY_DOWN | KCG_EVENT_KEY_UP | KCG_EVENT_FLAGS_CHANGED) { + LAST_TAP_KEY_EVENT_MS.store(now_ms(), Ordering::Relaxed); + } + let raw = raw_event(event_type, event); let keycode = raw.keycode; diff --git a/src-tauri/src/fnkey_fsm.rs b/src-tauri/src/fnkey_fsm.rs index c712b16..6044eaa 100644 --- a/src-tauri/src/fnkey_fsm.rs +++ b/src-tauri/src/fnkey_fsm.rs @@ -122,6 +122,63 @@ pub fn fn_stale_check(tap_says_held: bool, nsevent_fn_set: bool, ticks: u64) -> } } +/// How much later than the tap's last event the hardware must have seen a +/// keyboard event before the tap is suspected of missing it. +pub const DEAF_MARGIN_MS: u64 = 500; +/// How old that hardware event must be, so a tap merely a few milliseconds +/// behind — the event is queued on the main run loop — is not accused. +pub const DEAF_SETTLE_MS: u64 = 300; +/// TTP's own injected keystrokes are left out: an event it posted itself can +/// reach the HID clock by a path the tap does not sit on. +pub const DEAF_INJECTION_GRACE_MS: u64 = 2_000; + +/// Outcome of one deaf-tap check. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct DeafCheck { + /// The hardware saw a keyboard event the tap has not delivered. + pub suspect: bool, + /// Suspected on two passes in a row: the tap is deaf. + pub deaf: bool, + /// How far the tap's last event lags the hardware's, in ms. + pub missed_ms: u64, +} + +/// Decide whether the tap has stopped receiving key events while macOS still +/// calls it enabled. +/// +/// On 2026-09-27 Amir could not dictate from anywhere between 16:22 and 16:37: +/// the trace holds no `hotkey.press`, `ignored` or `event_dropped` in that +/// span, and every `tap_health` says `enabled:true`. Installing an update +/// relaunched TTP and it worked again. A tap that is enabled but receives +/// nothing is, to every existing check, indistinguishable from nobody +/// pressing anything — the watchdog comment says as much. +/// +/// The hardware keeps its own clock of the last keyboard event +/// (`CGEventSourceSecondsSinceLastEventType` on the HID state), independent of +/// any tap. `hid_last_ms` is that event's wall time, `tap_last_ms` the last +/// event the tap delivered (or when it was installed). A hardware event the +/// tap has not seen for a whole watchdog pass is a missed event; seen on two +/// passes in a row, the tap is deaf. +/// +/// Not accused: while secure input is on (a password field — keys reach no +/// tap by design), or when the gap sits inside TTP's own injection +/// (`injection_end_ms`, 0 when there has been none). +pub fn deaf_check( + hid_last_ms: u64, + tap_last_ms: u64, + injection_end_ms: u64, + now_ms: u64, + was_suspect: bool, + secure_input: bool, +) -> DeafCheck { + let missed_ms = hid_last_ms.saturating_sub(tap_last_ms); + let settled = now_ms.saturating_sub(hid_last_ms) >= DEAF_SETTLE_MS; + let ours = injection_end_ms != 0 + && hid_last_ms <= injection_end_ms.saturating_add(DEAF_INJECTION_GRACE_MS); + let suspect = !secure_input && !ours && settled && missed_ms >= DEAF_MARGIN_MS; + DeafCheck { suspect, deaf: suspect && was_suspect, missed_ms } +} + /// Snapshot of every piece of state the Fn timer needs to make a decision. /// The wrapper in `fnkey.rs` builds this from atomics on every tick. #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -295,6 +352,47 @@ pub fn fn_decide(state: FnFsmState, fn_held_now: bool, now_ms: u64) -> FnDecisio mod tests { use super::*; + // ── Deaf tap: enabled, receiving nothing ───────────────────────────── + + #[test] + fn a_tap_that_saw_the_last_key_is_not_deaf() { + let c = deaf_check(10_000, 10_000, 0, 12_000, true, false); + assert!(!c.suspect && !c.deaf); + } + + #[test] + fn a_missed_key_is_suspected_then_deaf_on_the_next_pass() { + let first = deaf_check(10_000, 4_000, 0, 12_000, false, false); + assert!(first.suspect && !first.deaf); + assert_eq!(first.missed_ms, 6_000); + let second = deaf_check(10_000, 4_000, 0, 14_000, first.suspect, false); + assert!(second.deaf); + } + + #[test] + fn a_key_still_in_flight_is_not_accused() { + // The hardware saw it 100 ms ago; the run loop has not delivered it yet. + assert!(!deaf_check(10_000, 4_000, 0, 10_100, true, false).suspect); + } + + #[test] + fn a_tap_that_caught_up_clears_the_suspicion() { + let c = deaf_check(10_000, 10_050, 0, 14_000, true, false); + assert!(!c.suspect && !c.deaf); + } + + #[test] + fn secure_input_is_not_deafness() { + assert!(!deaf_check(10_000, 4_000, 0, 14_000, true, true).suspect); + } + + #[test] + fn our_own_injection_is_not_a_missed_key() { + assert!(!deaf_check(10_000, 4_000, 9_000, 14_000, true, false).suspect); + // Well after the injection, the same gap counts again. + assert!(deaf_check(20_000, 4_000, 9_000, 24_000, true, false).deaf); + } + fn st(fn_was_held: bool, recording_active: bool) -> FnFsmState { FnFsmState { fn_was_held, diff --git a/src-tauri/src/input_route.rs b/src-tauri/src/input_route.rs new file mode 100644 index 0000000..b1ea69e --- /dev/null +++ b/src-tauri/src/input_route.rs @@ -0,0 +1,292 @@ +// TTP - Talk To Paste +// Which microphone a dictation records from when the user has not picked one. +// +// Opening a Bluetooth headset's microphone switches the headset from its +// music profile to its call profile for as long as the microphone is open: +// everything the user is listening to drops to call quality, and on AirPods +// the capture itself runs at 24 kHz (every AirPods `capture.start` in the +// 2026-09-27 trace says `rate: 24000`). Waking that profile is also what makes +// Bluetooth starts slow — the 2026-08-30 race began with a 544 ms build. +// +// So when the OS default input is Bluetooth and the Mac has a microphone of +// its own, TTP records from the Mac's. The headphones stay in music quality +// and the start is instant. Two exceptions keep the Bluetooth microphone: +// the lid is closed (Apple silicon and T2 MacBooks disconnect the built-in +// microphone in hardware, so it would record zeros), and the user pinned a +// device in Settings, which always wins. + +/// Why a capture uses the device it uses. Written into `capture.start`. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Route { + /// The user picked this device in Settings. + Pinned, + /// The user's pick is not connected; the OS default stands in. + PinnedMissing, + /// The OS default input, which is not Bluetooth (or not a Mac). + SystemDefault, + /// The OS default is Bluetooth; the Mac's own microphone records instead. + BuiltInOverBluetooth, + /// The OS default is Bluetooth and stays: the lid is closed, so the + /// built-in microphone is disconnected. + BluetoothLidClosed, + /// The OS default is Bluetooth and stays: this Mac has no microphone of + /// its own (a Mac mini, a Mac Pro). + BluetoothNoBuiltIn, +} + +impl Route { + pub fn slug(self) -> &'static str { + match self { + Route::Pinned => "pinned", + Route::PinnedMissing => "pinned_missing", + Route::SystemDefault => "system_default", + Route::BuiltInOverBluetooth => "builtin_over_bluetooth", + Route::BluetoothLidClosed => "bluetooth_lid_closed", + Route::BluetoothNoBuiltIn => "bluetooth_no_builtin", + } + } +} + +/// What the system looks like right now, as far as choosing a microphone goes. +#[derive(Debug, Default)] +pub struct InputScene { + pub default_is_bluetooth: bool, + /// Name of the Mac's own input device, as cpal names it. + pub builtin_input: Option, + pub lid_closed: bool, +} + +/// The decision, separate from the CoreAudio reads so it can be tested. +/// +/// Returns the route, and the name of the device to open instead of the OS +/// default when there is one. +pub fn choose(scene: &InputScene) -> (Route, Option<&str>) { + if !scene.default_is_bluetooth { + return (Route::SystemDefault, None); + } + match scene.builtin_input.as_deref() { + None => (Route::BluetoothNoBuiltIn, None), + Some(_) if scene.lid_closed => (Route::BluetoothLidClosed, None), + Some(name) => (Route::BuiltInOverBluetooth, Some(name)), + } +} + +/// Read the scene from CoreAudio and IOKit. Anything that cannot be read +/// comes back as its harmless value: not Bluetooth, no built-in, lid open. +#[cfg(target_os = "macos")] +pub fn scene() -> InputScene { + mac::scene() +} + +#[cfg(not(target_os = "macos"))] +pub fn scene() -> InputScene { + InputScene::default() +} + +#[cfg(target_os = "macos")] +mod mac { + use super::InputScene; + use core_foundation::base::{CFType, TCFType}; + use core_foundation::boolean::CFBoolean; + use core_foundation::string::{CFString, CFStringRef}; + use std::ffi::c_void; + + #[repr(C)] + struct Address { + selector: u32, + scope: u32, + element: u32, + } + + #[link(name = "CoreAudio", kind = "framework")] + extern "C" { + fn AudioObjectGetPropertyDataSize( + object: u32, + address: *const Address, + qualifier_size: u32, + qualifier: *const c_void, + data_size: *mut u32, + ) -> i32; + fn AudioObjectGetPropertyData( + object: u32, + address: *const Address, + qualifier_size: u32, + qualifier: *const c_void, + data_size: *mut u32, + data: *mut c_void, + ) -> i32; + } + + #[link(name = "IOKit", kind = "framework")] + extern "C" { + fn IOServiceMatching(name: *const std::ffi::c_char) -> *mut c_void; + fn IOServiceGetMatchingService(main_port: u32, matching: *mut c_void) -> u32; + fn IORegistryEntryCreateCFProperty( + entry: u32, + key: CFStringRef, + allocator: *const c_void, + options: u32, + ) -> *const c_void; + fn IOObjectRelease(object: u32) -> i32; + } + + const fn fourcc(code: &[u8; 4]) -> u32 { + u32::from_be_bytes(*code) + } + const SYSTEM_OBJECT: u32 = 1; + const SCOPE_GLOBAL: u32 = fourcc(b"glob"); + const SCOPE_INPUT: u32 = fourcc(b"inpt"); + const DEVICES: u32 = fourcc(b"dev#"); + const DEFAULT_INPUT: u32 = fourcc(b"dIn "); + const TRANSPORT: u32 = fourcc(b"tran"); + const STREAMS: u32 = fourcc(b"stm#"); + const NAME: u32 = fourcc(b"lnam"); + const TRANSPORT_BUILT_IN: u32 = fourcc(b"bltn"); + const TRANSPORT_BLUETOOTH: u32 = fourcc(b"blue"); + const TRANSPORT_BLUETOOTH_LE: u32 = fourcc(b"blea"); + + fn read_u32(object: u32, selector: u32, scope: u32) -> Option { + let address = Address { selector, scope, element: 0 }; + let mut value: u32 = 0; + let mut size = std::mem::size_of::() as u32; + // SAFETY: `value` is a live u32 and `size` says so. + let status = unsafe { + AudioObjectGetPropertyData(object, &address, 0, std::ptr::null(), &mut size, &mut value as *mut u32 as *mut c_void) + }; + (status == 0).then_some(value) + } + + fn size_of_property(object: u32, selector: u32, scope: u32) -> Option { + let address = Address { selector, scope, element: 0 }; + let mut size: u32 = 0; + // SAFETY: only writes `size`. + let status = unsafe { AudioObjectGetPropertyDataSize(object, &address, 0, std::ptr::null(), &mut size) }; + (status == 0).then_some(size) + } + + fn devices() -> Vec { + let Some(size) = size_of_property(SYSTEM_OBJECT, DEVICES, SCOPE_GLOBAL) else { + return Vec::new(); + }; + let mut ids = vec![0u32; size as usize / std::mem::size_of::()]; + let mut size = size; + let address = Address { selector: DEVICES, scope: SCOPE_GLOBAL, element: 0 }; + // SAFETY: `ids` holds exactly `size` bytes. + let status = unsafe { + AudioObjectGetPropertyData(SYSTEM_OBJECT, &address, 0, std::ptr::null(), &mut size, ids.as_mut_ptr() as *mut c_void) + }; + if status != 0 { + return Vec::new(); + } + ids.truncate(size as usize / std::mem::size_of::()); + ids + } + + fn name(device: u32) -> Option { + let address = Address { selector: NAME, scope: SCOPE_GLOBAL, element: 0 }; + let mut value: CFStringRef = std::ptr::null(); + let mut size = std::mem::size_of::() as u32; + // SAFETY: `value` is a live pointer-sized slot; the HAL returns a +1 + // CFString, which the create rule below takes ownership of. + let status = unsafe { + AudioObjectGetPropertyData(device, &address, 0, std::ptr::null(), &mut size, &mut value as *mut CFStringRef as *mut c_void) + }; + if status != 0 || value.is_null() { + return None; + } + Some(unsafe { CFString::wrap_under_create_rule(value) }.to_string()) + } + + fn has_input(device: u32) -> bool { + size_of_property(device, STREAMS, SCOPE_INPUT).is_some_and(|s| s > 0) + } + + fn is_bluetooth(transport: u32) -> bool { + transport == TRANSPORT_BLUETOOTH || transport == TRANSPORT_BLUETOOTH_LE + } + + fn lid_closed() -> bool { + // SAFETY: IOKit calls with a static C string; the service handle and + // the +1 CF property are both released. + unsafe { + let matching = IOServiceMatching(c"IOPMrootDomain".as_ptr()); + if matching.is_null() { + return false; + } + // Consumes `matching`. 0 is kIOMainPortDefault. + let service = IOServiceGetMatchingService(0, matching); + if service == 0 { + return false; + } + let key = CFString::from_static_string("AppleClamshellState"); + let value = IORegistryEntryCreateCFProperty(service, key.as_concrete_TypeRef(), std::ptr::null(), 0); + IOObjectRelease(service); + if value.is_null() { + // Desktops have no clamshell: no lid, so not closed. + return false; + } + let value = CFType::wrap_under_create_rule(value as _); + value.downcast::().map(bool::from).unwrap_or(false) + } + } + + pub fn scene() -> InputScene { + let default_is_bluetooth = read_u32(SYSTEM_OBJECT, DEFAULT_INPUT, SCOPE_GLOBAL) + .filter(|&id| id != 0) + .and_then(|id| read_u32(id, TRANSPORT, SCOPE_GLOBAL)) + .is_some_and(is_bluetooth); + if !default_is_bluetooth { + // Nothing else matters; skip the device walk and the IOKit read. + return InputScene::default(); + } + let builtin_input = devices() + .into_iter() + .find(|&id| read_u32(id, TRANSPORT, SCOPE_GLOBAL) == Some(TRANSPORT_BUILT_IN) && has_input(id)) + .and_then(name); + InputScene { default_is_bluetooth, builtin_input, lid_closed: lid_closed() } + } + + #[cfg(test)] + mod tests { + /// Talks to the real HAL and IOKit: every read must come back without + /// crashing, whatever this machine has plugged in. + #[test] + fn the_scene_reads_on_a_real_mac() { + eprintln!("scene on this Mac: {:?}", super::scene()); + let _ = super::lid_closed(); + assert!(!super::devices().is_empty(), "the HAL listed no devices"); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn scene(bt: bool, builtin: Option<&str>, lid_closed: bool) -> InputScene { + InputScene { default_is_bluetooth: bt, builtin_input: builtin.map(String::from), lid_closed } + } + + #[test] + fn a_wired_default_is_left_alone() { + assert_eq!(choose(&scene(false, Some("MacBook Air Microphone"), false)), (Route::SystemDefault, None)); + } + + #[test] + fn bluetooth_default_records_from_the_mac() { + assert_eq!( + choose(&scene(true, Some("MacBook Air Microphone"), false)), + (Route::BuiltInOverBluetooth, Some("MacBook Air Microphone")) + ); + } + + #[test] + fn a_closed_lid_keeps_the_headset() { + assert_eq!(choose(&scene(true, Some("MacBook Air Microphone"), true)), (Route::BluetoothLidClosed, None)); + } + + #[test] + fn a_mac_without_a_microphone_keeps_the_headset() { + assert_eq!(choose(&scene(true, None, false)), (Route::BluetoothNoBuiltIn, None)); + } +} diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f10f921..85954b1 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,8 +21,10 @@ mod fnkey_fsm; mod history; mod http_client; mod i18n; +mod input_route; mod keychain; mod licensing; +mod mic_release; // crate-type = ["staticlib", "cdylib", "rlib"] means anything `pub` is // visible to downstream linkers. `logging` and `transcription` are internal // implementation details; `pub(crate)` makes their visibility match their @@ -380,15 +382,39 @@ async fn download_update_with_channel( app: AppHandle, use_beta: bool, ) -> Result { - let updater = build_channel_updater(&app, use_beta)?; + stage_latest_update(&app, use_beta) + .await? + .ok_or_else(|| "No update available on this channel".to_string()) +} - let update = updater +/// Check the channel and, when there is something newer, download and stage +/// it for [`start_idle_applier`]. `Ok(None)` means up to date. +/// +/// Shared by the Settings button, the webview's launch check and +/// [`start_background_update_checks`]. A version already staged is not +/// downloaded again: before this, each path fetched its own copy. +async fn stage_latest_update(app: &AppHandle, use_beta: bool) -> Result, String> { + let updater = build_channel_updater(app, use_beta)?; + + let update = match updater .check() .await .map_err(|e| format!("Update check failed: {}", e))? - .ok_or_else(|| "No update available on this channel".to_string())?; + { + Some(update) => update, + None => return Ok(None), + }; let version = update.version.clone(); + let already_staged = STAGED_UPDATE + .lock() + .ok() + .and_then(|g| g.as_ref().map(|(u, _)| u.version == version)) + .unwrap_or(false); + if already_staged { + start_idle_applier(app.clone()); + return Ok(Some(version)); + } let started = std::time::Instant::now(); let progress_app = app.clone(); @@ -429,8 +455,56 @@ async fn download_update_with_channel( if let Ok(mut staged) = STAGED_UPDATE.lock() { *staged = Some((update, bytes)); } - start_idle_applier(app); - Ok(version) + start_idle_applier(app.clone()); + Ok(Some(version)) +} + +/// How often TTP looks for an update on its own. +const BACKGROUND_CHECK_EVERY: std::time::Duration = std::time::Duration::from_secs(60 * 60); +/// The first background check, a little after launch: the webview checks at +/// launch too, and the staged-version guard makes the overlap free. +const BACKGROUND_FIRST_CHECK_AFTER: std::time::Duration = std::time::Duration::from_secs(5 * 60); + +/// Look for updates every hour, from Rust, and stage whatever is found. +/// +/// The periodic check used to be a 4-hour `setInterval` in the hidden main +/// window. macOS App Naps that window and its timers stop (the idle applier +/// learnt this on 2026-09-24), so in practice TTP checked once, at launch. +/// On 2026-09-27 3.2.4 was out all afternoon and the session running since +/// 10:17 never heard of it; Amir found it only by pressing "Check" at 16:37. +/// +/// Wall-clock based: a thread's sleep does not advance while the Mac sleeps, +/// so it wakes every minute and compares real time. Staging hands over to +/// [`start_idle_applier`], which installs after two quiet minutes — the user +/// is never asked and never interrupted mid-dictation. +fn start_background_update_checks(app: AppHandle) { + std::thread::Builder::new() + .name("ttp-update-checks".into()) + .spawn(move || { + let mut next = std::time::SystemTime::now() + BACKGROUND_FIRST_CHECK_AFTER; + loop { + std::thread::sleep(std::time::Duration::from_secs(60)); + if std::time::SystemTime::now() < next { + continue; + } + next = std::time::SystemTime::now() + BACKGROUND_CHECK_EVERY; + let staged = STAGED_UPDATE.lock().map(|g| g.is_some()).unwrap_or(false); + if staged { + continue; + } + let outcome = tauri::async_runtime::block_on(stage_latest_update(&app, false)); + trace::event( + "update.checked", + match outcome { + Ok(Some(version)) => serde_json::json!({ "trigger": "background", "staged": version }), + Ok(None) => serde_json::json!({ "trigger": "background", "up_to_date": true }), + Err(e) => serde_json::json!({ "trigger": "background", "error": e }), + }, + ); + } + }) + .map_err(|e| trace::degraded("update.background_checks", serde_json::json!({ "error": e.to_string() }))) + .ok(); } fn self_update_marker_path(app: &AppHandle) -> Option { @@ -1105,6 +1179,7 @@ pub fn run() { }), ); consume_self_update_marker(app.handle()); + start_background_update_checks(app.handle().clone()); // Pay the keychain's one-time ACL evaluation now, on a blocking // thread nobody waits on, rather than during the first dictation diff --git a/src-tauri/src/mic_release.rs b/src-tauri/src/mic_release.rs new file mode 100644 index 0000000..d87271d --- /dev/null +++ b/src-tauri/src/mic_release.rs @@ -0,0 +1,209 @@ +// TTP - Talk To Paste +// Asks CoreAudio, after every capture is torn down, whether the microphone +// really went off. +// +// Every other capture check in the trace (`capture.orphan_*`, +// `capture.stale_dropped`, the arbiter) reads TTP's own bookkeeping: they +// prove TTP *dropped* the stream, not that the operating system closed it. +// On 2026-09-27 those two came apart. cpal 0.15.3 gave any stream opened on a +// device picked by name (not the OS default) a disconnect listener holding a +// strong reference back to the stream, so dropping it freed nothing: each +// dictation on a pinned "MacBook Air Microphone" left one more AudioUnit +// running, the orange dot stayed on, and the trace showed eight clean +// `capture.start` / `capture.stop` pairs. cpal 0.16 fixed the cycle; this +// module is the check that would have caught it on the first dictation. +// +// The question is per-process — "is TTP running input?" — not per-device, +// so another app using the same microphone (a call, a recorder) is never +// mistaken for a leak. + +use std::sync::atomic::{AtomicU64, Ordering}; +use std::time::Duration; + +/// Bumped every time a capture stream is about to be built. A check that +/// finds it moved since its drop knows a newer dictation owns the +/// microphone now, and that "input is running" is that dictation, not a leak. +static STREAMS_BUILT: AtomicU64 = AtomicU64::new(0); + +/// First look after the drop. CoreAudio stops the unit synchronously; this is +/// margin for the HAL to publish it. +const FIRST_LOOK_MS: u64 = 300; +/// Second look, only when the first still saw input running: long enough that +/// a slow Bluetooth teardown is not reported as a leak. +const SECOND_LOOK_MS: u64 = 2_000; + +/// Call immediately before building a capture stream. +pub fn note_stream_built() { + STREAMS_BUILT.fetch_add(1, Ordering::SeqCst); +} + +/// Call right after a capture stream has been dropped. `site` names the path +/// that dropped it (`stop`, `orphan_prevented`, `orphan_reclaimed`, +/// `stale_dropped`). Returns immediately; the check runs on its own thread. +pub fn verify_after_drop(site: &'static str) { + let generation = STREAMS_BUILT.load(Ordering::SeqCst); + let device = crate::audio_capture::last_capture_device(); + let spawned = std::thread::Builder::new() + .name("ttp-mic-release".into()) + .spawn(move || check(site, generation, device)); + if let Err(e) = spawned { + crate::trace::degraded( + "capture.mic_release", + serde_json::json!({ "site": site, "error": format!("thread spawn: {}", e) }), + ); + } +} + +enum Look { + Off, + Live, + /// A newer capture started after the drop; the answer would be about it. + Superseded, + /// CoreAudio could not answer (before macOS 14, or not a Mac). + Unknown(String), +} + +fn look(generation: u64) -> Look { + if STREAMS_BUILT.load(Ordering::SeqCst) != generation { + return Look::Superseded; + } + match input_running_in_this_process() { + Ok(true) => Look::Live, + Ok(false) => Look::Off, + Err(e) => Look::Unknown(e), + } +} + +fn check(site: &'static str, generation: u64, device: Option) { + std::thread::sleep(Duration::from_millis(FIRST_LOOK_MS)); + let (result, after_ms) = match look(generation) { + Look::Live => { + std::thread::sleep(Duration::from_millis(SECOND_LOOK_MS - FIRST_LOOK_MS)); + (look(generation), SECOND_LOOK_MS) + } + other => (other, FIRST_LOOK_MS), + }; + match result { + Look::Off => crate::trace::event( + "capture.mic_released", + serde_json::json!({ "site": site, "released": true, "after_ms": after_ms }), + ), + Look::Superseded => crate::trace::event( + "capture.mic_released", + serde_json::json!({ "site": site, "released": null, "reason": "newer_capture", "after_ms": after_ms }), + ), + Look::Unknown(e) => crate::trace::event( + "capture.mic_released", + serde_json::json!({ "site": site, "released": null, "reason": e, "after_ms": after_ms }), + ), + Look::Live => { + crate::logging::log_error(&format!( + "[AudioCapture] microphone still live {} ms after the capture was dropped ({}, device {:?}) — the stream leaked below TTP", + after_ms, site, device + )); + crate::trace::event( + "capture.mic_still_live", + serde_json::json!({ "site": site, "device": device, "after_ms": after_ms }), + ); + } + } +} + +/// Whether CoreAudio has input running for this process right now. +#[cfg(target_os = "macos")] +fn input_running_in_this_process() -> Result { + use std::ffi::c_void; + + #[repr(C)] + struct AudioObjectPropertyAddress { + selector: u32, + scope: u32, + element: u32, + } + + #[link(name = "CoreAudio", kind = "framework")] + extern "C" { + fn AudioObjectGetPropertyData( + object: u32, + address: *const AudioObjectPropertyAddress, + qualifier_size: u32, + qualifier: *const c_void, + data_size: *mut u32, + data: *mut c_void, + ) -> i32; + } + + const SYSTEM_OBJECT: u32 = 1; + const SCOPE_GLOBAL: u32 = u32::from_be_bytes(*b"glob"); + const ELEMENT_MAIN: u32 = 0; + // kAudioHardwarePropertyTranslatePIDToProcessObject, macOS 14+. + const TRANSLATE_PID: u32 = u32::from_be_bytes(*b"id2p"); + // kAudioProcessPropertyIsRunningInput, macOS 14+. + const IS_RUNNING_INPUT: u32 = u32::from_be_bytes(*b"piri"); + + let pid = std::process::id() as i32; + let mut process: u32 = 0; + let mut size = std::mem::size_of::() as u32; + let address = AudioObjectPropertyAddress { selector: TRANSLATE_PID, scope: SCOPE_GLOBAL, element: ELEMENT_MAIN }; + // SAFETY: every pointer is to a live local of the size passed with it. + let status = unsafe { + AudioObjectGetPropertyData( + SYSTEM_OBJECT, + &address, + std::mem::size_of::() as u32, + &pid as *const i32 as *const c_void, + &mut size, + &mut process as *mut u32 as *mut c_void, + ) + }; + if status != 0 { + return Err(format!("pid_lookup_status_{}", status)); + } + // The HAL has no process object for us: nothing of ours is running. + if process == 0 { + return Ok(false); + } + + let mut running: u32 = 0; + let mut size = std::mem::size_of::() as u32; + let address = AudioObjectPropertyAddress { selector: IS_RUNNING_INPUT, scope: SCOPE_GLOBAL, element: ELEMENT_MAIN }; + // SAFETY: as above. + let status = unsafe { + AudioObjectGetPropertyData( + process, + &address, + 0, + std::ptr::null(), + &mut size, + &mut running as *mut u32 as *mut c_void, + ) + }; + if status != 0 { + return Err(format!("running_input_status_{}", status)); + } + Ok(running != 0) +} + +#[cfg(not(target_os = "macos"))] +fn input_running_in_this_process() -> Result { + Err("unsupported_os".into()) +} + +#[cfg(all(test, target_os = "macos"))] +mod tests { + use super::*; + + /// Talks to the real HAL. A test process has opened no input, so the + /// answer must be a clean "off" — an error here means the FFI is wrong. + #[test] + fn a_process_with_no_capture_reads_as_off() { + assert_eq!(input_running_in_this_process(), Ok(false)); + } + + #[test] + fn a_newer_capture_supersedes_the_check() { + let generation = STREAMS_BUILT.load(Ordering::SeqCst); + note_stream_built(); + assert!(matches!(look(generation), Look::Superseded)); + } +} diff --git a/src-tauri/src/paste/input_marks.rs b/src-tauri/src/paste/input_marks.rs index f98fbeb..319c91b 100644 --- a/src-tauri/src/paste/input_marks.rs +++ b/src-tauri/src/paste/input_marks.rs @@ -45,6 +45,22 @@ pub fn note_key_down(source_pid: i64, keycode: u16) { } } +/// Wall-clock ms at which TTP last posted synthetic input. Set as each +/// injection starts and again as it ends, so it covers the whole of it. The +/// hotkey's deaf-tap check leaves this window out: an event TTP posted can +/// reach the hardware's last-event clock by a path the tap does not sit on. +static LAST_INJECTION_MS: AtomicU64 = AtomicU64::new(0); + +/// Called by `simulate` around every post. +pub fn note_injection() { + LAST_INJECTION_MS.store(now_ms(), Ordering::Relaxed); +} + +/// When TTP last posted input, 0 if it never has. +pub fn last_injection_ms() -> u64 { + LAST_INJECTION_MS.load(Ordering::Relaxed) +} + /// A point to measure from: take one before injecting, diff it afterwards. #[derive(Debug, Clone, Copy)] pub struct InputMark { diff --git a/src-tauri/src/paste/simulate.rs b/src-tauri/src/paste/simulate.rs index 9bf71f9..0cc6c85 100644 --- a/src-tauri/src/paste/simulate.rs +++ b/src-tauri/src/paste/simulate.rs @@ -359,9 +359,11 @@ pub fn simulate_paste() -> Result<(), String> { key_up.set_flags(flags); // Post the events to the annotated session (current user session) + super::input_marks::note_injection(); key_down.post(CGEventTapLocation::AnnotatedSession); thread::sleep(Duration::from_millis(10)); key_up.post(CGEventTapLocation::AnnotatedSession); + super::input_marks::note_injection(); } #[cfg(target_os = "windows")] @@ -417,6 +419,7 @@ pub fn simulate_typing(text: &str) -> Result<(), String> { let source = CGEventSource::new(CGEventSourceStateID::Private) .map_err(|_| "Failed to create event source")?; + super::input_marks::note_injection(); let chunks = mac::injection_chunks(text, mac::chunk_budget()); let garble = fault_garble_this_injection(); let last = chunks.len().saturating_sub(1); @@ -440,6 +443,7 @@ pub fn simulate_typing(text: &str) -> Result<(), String> { // Matches enigo's trailing pause — gives the target's input queue a // moment to drain before the caller restores the clipboard. thread::sleep(Duration::from_millis(2)); + super::input_marks::note_injection(); } #[cfg(not(target_os = "macos"))] diff --git a/src-tauri/src/transcription/pipeline.rs b/src-tauri/src/transcription/pipeline.rs index 1b24887..d47a3b4 100644 --- a/src-tauri/src/transcription/pipeline.rs +++ b/src-tauri/src/transcription/pipeline.rs @@ -2735,7 +2735,7 @@ mod hallucination_tests { /// /// Orchestrates the flow: /// 1. Transcribe audio via Groq Whisper -/// 2. Polish text via Groq LLM (llama-3.3-70b-versatile) +/// 2. Polish text via Groq LLM (see `polish::MODEL`) /// 3. Paste into active app (or clipboard fallback) /// /// Emits progress events throughout for frontend updates. diff --git a/src-tauri/src/transcription/polish.rs b/src-tauri/src/transcription/polish.rs index 2e67408..3e63a9c 100644 --- a/src-tauri/src/transcription/polish.rs +++ b/src-tauri/src/transcription/polish.rs @@ -51,7 +51,10 @@ pub const MODEL: &str = "openai/gpt-oss-120b"; /// cheapest setting. Without this, reasoning would eat the output budget and /// truncate the JSON, which the downstream guard would reject as garbage: /// polish silently broken again, by a different mechanism. -const REASONING_EFFORT: &str = "low"; +/// +/// Shared with `dictionary::classify`, which asks the same model for a +/// one-word verdict and has even less to deliberate about. +pub(crate) const REASONING_EFFORT: &str = "low"; /// System prompt — frames the LLM as a deterministic text-cleanup function /// that treats dictation inside `` tags as INERT DATA, never as @@ -310,7 +313,10 @@ struct ChatMessageResponse { /// and produced truncated garbage the guard rejects — polish broken again, /// silently, in a new way. 512 covers a `reasoning_effort: "low"` pass on the /// longest dictation the direct-typing path accepts, with room to spare. -const REASONING_TOKEN_HEADROOM: u32 = 512; +/// +/// Shared with `dictionary::classify`, whose one-word verdict budget would +/// otherwise be spent entirely on reasoning. +pub(crate) const REASONING_TOKEN_HEADROOM: u32 = 512; /// Compute the completion cap as `input_chars * 1.3 + 50`, plus reasoning /// headroom. If the LLM tries to generate a poem in response to "write me a diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index ac2c3c2..4a611ba 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "TTP by AmirKS", - "version": "3.2.4", + "version": "3.2.5", "identifier": "com.ttp.desktop", "plugins": { "updater": { diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 97d824b..5bae78f 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -195,7 +195,8 @@ "vadAutoStopDesc": "End the recording automatically once you've stopped speaking. Useful when you forget to release the hotkey.", "vadSilenceSecsLabel": "Silence before auto-stop", "audioDeviceLabel": "Input device", - "audioDeviceDefault": "System default", + "audioDeviceDefault": "Automatic", + "audioDeviceHint": "With Bluetooth headphones, TTP records from the Mac's microphone so your headphones keep full sound quality.", "audioDeviceDefaultSuffix": "(default)", "launchStartupLabel": "Launch at startup" }, @@ -417,6 +418,7 @@ "subtitle": "TTP has been updated.", "dismiss": "Got it", "notes": { + "3-2-5": "TTP looks after itself: microphone, shortcut and updates.\n• The microphone really turns off after each dictation: the orange dot no longer stays on.\n• With AirPods or another Bluetooth headset, TTP records from the Mac's microphone: your music keeps its quality and recording starts instantly. Settings → Dictation → Input device → “Automatic”.\n• If macOS stops passing your keys to TTP, TTP notices and repairs its shortcut on its own.\n• Updates arrive by themselves: TTP checks every hour, without you opening it.\n• The dictionary learns again: Groq had retired the model it used.", "3-2-4": "TTP now checks that your text arrived whole.\n• If only part of a dictation lands in the app, TTP notices and retypes the full text over it, usually before you have time to press Return.\n• If it cannot fix it, the full text is on your clipboard and the pill says so: ⌘V pastes it in full.", "3-2-3": "TTP no longer loses your dictations in the pauses.\n• Long dictations with pauses, or spoken quietly, are no longer mistaken for silence and thrown away.\n• If TTP still judges a recording silent, it keeps it for 24 h instead of deleting it.\n• Updates without interruption: TTP downloads the new version in the background, then relaunches itself during a two-minute pause.\n• “Auto” means French or English, never a third language.\n• Settings no longer freeze.", "3-2-2": "TTP is simpler, and still completely free.\n• Any shortcut you like: Fn, a key, a modifier or a mouse button. Add a second one if you want, handy with an external keyboard.\n• Your screen: setup now asks whether TTP may look at the text around your cursor to spell names right. Turn it on or off in Settings → Dictation.\n• Setup says plainly where your voice goes (to Groq) before asking for your key.\n• Guided setup: allow TTP by dragging it into System Settings, then try a first dictation.\n• Redesigned settings: a few short pages instead of one long list.\n• Faster at the end of every dictation.\n• Numbers stay whole: “3.2.2” no longer turns into “3.2”, and the word “point” is no longer swapped for a “.”.\n• Two dictations in a row work every time. If you start the next one before the first is pasted, the pill tells you it lands when you let go.\n• ✓✓ when the text is pasted. If it could not be, it is waiting in History.\n• Something wrong? Settings → Advanced → “Report a problem” prepares an e-mail with a report, without anything you dictated.\n• At the end of setup, you choose whether TTP sends crash reports.", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 508a2b5..d8c7c7d 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -195,7 +195,8 @@ "vadAutoStopDesc": "Termine l'enregistrement automatiquement quand tu as fini de parler. Pratique quand tu oublies de relâcher le raccourci.", "vadSilenceSecsLabel": "Silence avant arrêt auto", "audioDeviceLabel": "Source audio", - "audioDeviceDefault": "Source système", + "audioDeviceDefault": "Automatique", + "audioDeviceHint": "Avec des écouteurs Bluetooth, TTP enregistre avec le micro du Mac : le son dans les écouteurs garde sa qualité.", "audioDeviceDefaultSuffix": "(par défaut)", "launchStartupLabel": "Lancer au démarrage" }, @@ -417,6 +418,7 @@ "subtitle": "TTP a été mis à jour.", "dismiss": "Compris", "notes": { + "3-2-5": "TTP prend soin de lui-même : micro, raccourci et mises à jour.\n• Le micro se coupe vraiment après chaque dictée : le point orange ne reste plus allumé.\n• Avec des AirPods ou un autre casque Bluetooth, TTP enregistre avec le micro du Mac : ta musique garde sa qualité et l'enregistrement démarre tout de suite. Réglages → Dictée → Source audio → « Automatique ».\n• Si macOS cesse de transmettre tes touches à TTP, TTP s'en rend compte et répare son raccourci tout seul.\n• Les mises à jour arrivent toutes seules : TTP vérifie chaque heure, sans que tu l'ouvres.\n• Le dictionnaire réapprend : Groq avait retiré le modèle qu'il utilisait.", "3-2-4": "TTP vérifie maintenant que ton texte est arrivé en entier.\n• Si seule une partie d'une dictée arrive dans l'app, TTP s'en rend compte et retape le texte complet par-dessus, en général avant que tu aies le temps d'appuyer sur Entrée.\n• S'il ne peut pas réparer, le texte complet est dans ton presse-papiers et la pilule te le dit : ⌘V le colle en entier.", "3-2-3": "TTP ne perd plus tes dictées dans les silences.\n• Les longues dictées avec des pauses, ou dites à voix basse, ne sont plus prises pour du silence et jetées.\n• Si TTP juge quand même un enregistrement silencieux, il le garde 24 h au lieu de l'effacer.\n• Mises à jour sans coupure : TTP télécharge la nouvelle version en arrière-plan, puis se relance tout seul pendant une pause de deux minutes.\n• « Auto » veut dire français ou anglais, jamais une troisième langue.\n• Les Réglages ne se figent plus.", "3-2-2": "TTP est plus simple, et toujours entièrement gratuit.\n• Le raccourci de ton choix : Fn, une touche, un modificateur ou un bouton de souris. Et un deuxième si tu veux, pratique avec un clavier externe.\n• Ton écran : l'installation te demande maintenant si TTP peut regarder le texte autour de ton curseur pour bien écrire les noms. Réglages → Dictée pour l'activer ou le couper.\n• L'installation dit clairement où part ta voix (chez Groq) avant de te demander ta clé.\n• Installation guidée : tu autorises TTP en le glissant dans les Réglages Système, puis tu fais un premier essai.\n• Réglages repensés : quelques pages courtes au lieu d'une longue liste.\n• Plus rapide à la fin de chaque dictée.\n• Les nombres restent entiers : « 3.2.2 » ne devient plus « 3.2 », et « du point de vue » n'est plus coupé par un « . ».\n• Deux dictées d'affilée marchent à chaque fois. Si tu relances avant que la première soit collée, la pilule te dit qu'elle arrive quand tu lâches.\n• ✓✓ quand le texte est collé. S'il n'a pas pu l'être, il t'attend dans l'historique.\n• Un souci ? Réglages → Avancé → « Signaler un problème » prépare un e-mail avec un rapport, sans rien de ce que tu as dicté.\n• À la fin de l'installation, tu choisis si TTP envoie ses rapports de plantage.", diff --git a/src/windows/Settings.tsx b/src/windows/Settings.tsx index 75eb5c6..1815dc4 100644 --- a/src/windows/Settings.tsx +++ b/src/windows/Settings.tsx @@ -627,7 +627,7 @@ export function Settings() { - +