From 5eac68b5a819928f4fe7c2140ee022d9c5ec3bea Mon Sep 17 00:00:00 2001 From: Amir KELLOU SIDHOUM Date: Sun, 27 Sep 2026 17:41:47 +0200 Subject: [PATCH 01/11] capture: close the microphone for real, and ask CoreAudio whether it did cpal 0.15.3 gave every stream opened on a device picked by name a disconnect listener holding a strong reference back to the stream. Dropping it freed nothing, so each dictation on a pinned microphone left one more AudioUnit running: on 2026-09-27 eight clean capture.start/capture.stop pairs sat over a MacBook Air Microphone that never closed. cpal 0.16 breaks the cycle with a Weak; a scratch repro on this Mac shows input live after drop on 0.15.3 and off on 0.16.0. rodio keeps its own 0.15 for output, which never registers the listener. The trace could not see it because every capture check reads TTP's own bookkeeping. mic_release asks the HAL, 0.3 s and 2 s after each drop, whether this process still runs input: capture.mic_released normally, capture.mic_still_live when it leaked, and the analyser's new capture-mic-still-live check reports it. Co-Authored-By: Claude Opus 5.5 --- docs/trace-invariants.md | 7 +- docs/tracing.md | 3 + scripts/trace_analyser/invariants.py | 29 +++ .../broken/capture-mic-still-live.log | 12 + scripts/trace_analyser/tests/make_fixtures.py | 10 + src-tauri/Cargo.lock | 83 ++++++- src-tauri/Cargo.toml | 2 +- src-tauri/src/audio_capture.rs | 10 +- src-tauri/src/lib.rs | 1 + src-tauri/src/mic_release.rs | 209 ++++++++++++++++++ 10 files changed, 356 insertions(+), 10 deletions(-) create mode 100644 scripts/trace_analyser/tests/fixtures/broken/capture-mic-still-live.log create mode 100644 src-tauri/src/mic_release.rs 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..2ee8ce0 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -173,6 +173,8 @@ anything from a missing launch line. | `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. | @@ -565,6 +567,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' ``` diff --git a/scripts/trace_analyser/invariants.py b/scripts/trace_analyser/invariants.py index 68cb9a5..34be35d 100644 --- a/scripts/trace_analyser/invariants.py +++ b/scripts/trace_analyser/invariants.py @@ -119,6 +119,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 +838,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..7bfcc6f 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -986,6 +986,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" @@ -1003,7 +1017,7 @@ checksum = "873dab07c8f743075e57f524c583985fbaf745602acbe916a01539364369a779" dependencies = [ "alsa", "core-foundation-sys", - "coreaudio-rs", + "coreaudio-rs 0.11.3", "dasp_sample", "jni", "js-sys", @@ -1018,6 +1032,32 @@ dependencies = [ "windows 0.54.0", ] +[[package]] +name = "cpal" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cbd307f43cc2a697e2d1f8bc7a1d824b5269e052209e28883e5bc04d095aaa3f" +dependencies = [ + "alsa", + "coreaudio-rs 0.13.0", + "dasp_sample", + "jni", + "js-sys", + "libc", + "mach2", + "ndk 0.9.0", + "ndk-context", + "num-derive", + "num-traits", + "objc2-audio-toolbox", + "objc2-core-audio", + "objc2-core-audio-types", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows 0.54.0", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -3508,6 +3548,21 @@ 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-cloud-kit" version = "0.3.2" @@ -3519,6 +3574,28 @@ 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", +] + +[[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" @@ -4783,7 +4860,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", ] @@ -6618,7 +6695,7 @@ dependencies = [ "cocoa", "core-foundation 0.10.1", "core-graphics 0.24.0", - "cpal", + "cpal 0.16.0", "dirs 5.0.1", "enigo", "getrandom 0.3.4", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c6ecedc..9d07f39 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -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.16" 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..57be271 100644 --- a/src-tauri/src/audio_capture.rs +++ b/src-tauri/src/audio_capture.rs @@ -549,7 +549,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. @@ -648,6 +648,7 @@ 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() @@ -670,6 +671,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 +809,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 +842,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 +984,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(); diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index f10f921..da6e5f4 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -23,6 +23,7 @@ mod http_client; mod i18n; 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 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)); + } +} From e9f265289b598101903356ce30cbc3a42920549f Mon Sep 17 00:00:00 2001 From: Amir KELLOU SIDHOUM Date: Sun, 27 Sep 2026 17:52:15 +0200 Subject: [PATCH 02/11] capture: record from the Mac's microphone when the default is a Bluetooth headset Opening a headset's microphone puts it in its call profile for as long as the microphone is open, so whatever the user is listening to drops to call quality while they dictate (every AirPods capture in the 2026-09-27 trace ran at 24 kHz), and waking that profile is what makes Bluetooth starts slow. With no device pinned, input_route now picks the Mac's own microphone when the OS default input is Bluetooth. The headset stays when the lid is closed (the built-in microphone is disconnected in hardware) or the Mac has none; a pinned device still wins. capture.start carries the reason as `route`, and the Settings option reads "Automatique" with a line saying what it does. capture.stop's device_changed now compares the default at start with the default at stop, as documented. It compared the device opened with the default, so every pinned dictation reported a change that never happened. Co-Authored-By: Claude Opus 5.5 --- docs/tracing.md | 4 +- src-tauri/src/audio_capture.rs | 90 +++++++--- src-tauri/src/input_route.rs | 292 +++++++++++++++++++++++++++++++++ src-tauri/src/lib.rs | 1 + src/i18n/locales/en.json | 3 +- src/i18n/locales/fr.json | 3 +- src/windows/Settings.tsx | 2 +- 7 files changed, 366 insertions(+), 29 deletions(-) create mode 100644 src-tauri/src/input_route.rs diff --git a/docs/tracing.md b/docs/tracing.md index 2ee8ce0..2564743 100644 --- a/docs/tracing.md +++ b/docs/tracing.md @@ -166,8 +166,8 @@ 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. | diff --git a/src-tauri/src/audio_capture.rs b/src-tauri/src/audio_capture.rs index 57be271..d877f9f 100644 --- a/src-tauri/src/audio_capture.rs +++ b/src-tauri/src/audio_capture.rs @@ -103,34 +103,59 @@ 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()) } +fn input_device_named(host: &cpal::Host, name: &str) -> Option { + // cpal's `Device::name()` returns `Result`; + // compare the success arm rather than the Results. + host.input_devices() + .ok()? + .find(|device| device.name().ok().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 +166,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()) @@ -592,7 +621,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))?; @@ -622,6 +651,9 @@ 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()); @@ -631,6 +663,9 @@ 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, + // 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().0, "channels": config.channels(), "format": format!("{:?}", config.sample_format()), @@ -1011,15 +1046,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, }; @@ -1028,6 +1069,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 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 da6e5f4..3e67573 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -21,6 +21,7 @@ mod fnkey_fsm; mod history; mod http_client; mod i18n; +mod input_route; mod keychain; mod licensing; mod mic_release; diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 97d824b..a27c11c 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" }, diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index 508a2b5..fa7a372 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" }, 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() { - +