From b93327dc26b9f070fc198f258a257ee3f8738168 Mon Sep 17 00:00:00 2001 From: Alan Silva Date: Wed, 9 Sep 2026 12:17:06 +0100 Subject: [PATCH] Ask for the headset profile at capture start; relaunch the daemon on update The daemon now requests the Bluetooth headset profile itself (pactl set-card-profile) the moment a take starts, instead of waiting for WirePlumber to notice the stream and run its switch timer. The microphone link comes up 0.15-0.18 s after the key instead of 0.21-0.27 s; WirePlumber still restores A2DP when the stream goes away. First real audio is now at 0.82-0.86 s, which is the headset's own floor. When the plugin is updated while the daemon runs, the daemon sees its source change, exits with code 4 once idle, and the shell service relaunches it without counting that as a crash. Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_01Xoc9DJCViR7dg9eKzzAcfQ --- daemon/sttd.py | 46 ++++++++++++++++++++++++++- diagnostics/bluetooth/STARTUP-TIME.md | 39 +++++++++++------------ plugin/Service.qml | 5 +++ 3 files changed, 69 insertions(+), 21 deletions(-) diff --git a/daemon/sttd.py b/daemon/sttd.py index 0a60aae..fa4bea4 100755 --- a/daemon/sttd.py +++ b/daemon/sttd.py @@ -175,6 +175,9 @@ TERMINAL_CLASSES = {"alacritty", "kitty", "foot", "com.mitchellh.ghostty", "org. LOG_FILE = os.path.join(RUNTIME, "daemon.log") # The CLI next to this daemon: key bindings call it by absolute path, so they work whatever Hyprland's PATH is. +SOURCE = os.path.abspath(__file__) +SOURCE_MTIME = os.stat(SOURCE).st_mtime +EXIT_RELAUNCH = 4 # the source changed underneath us (plugin update): the shell service starts the new one STT_CLI = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "stt") @@ -516,6 +519,28 @@ class Binds: # recorder # --------------------------------------------------------------------------- +def bt_card_for(device): + """bluez_input.88:C9:E8:A7:EC:7E (or bluez_input.88_C9_....0) -> bluez_card.88_C9_E8_A7_EC_7E, else None.""" + if not device.startswith("bluez_input."): + return None + return "bluez_card." + device[len("bluez_input."):].split(".")[0].replace(":", "_") + + +def request_headset_profile(device): + """Ask for the headset (HFP) profile the moment capture starts. WirePlumber would do the same + once it notices the stream, plus its switch timer: asking first saves ~0.15 s on the microphone + link. Fire and forget: no-op if the profile is already active, and WirePlumber still restores + A2DP when the stream goes away, whoever switched.""" + card = bt_card_for(device) + if not card: + return + try: + subprocess.Popen(["pactl", "set-card-profile", card, "headset-head-unit"], + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) + except OSError: + pass + + class Recorder: def __init__(self, device): self.device = device @@ -1066,6 +1091,7 @@ class Daemon: self.loop = None self.stopping = False self.stop_event = None + self.exit_code = 0 # ---- state ---- def state_msg(self, full=False): @@ -1331,6 +1357,7 @@ class Daemon: self.warm.stop() self.warm = None self.rec = Recorder(self.cfg.get("device", "default")) + request_headset_profile(self.rec.device or "") try: self.rec.start() except (OSError, ValueError, TypeError) as e: @@ -1800,6 +1827,20 @@ class Daemon: if self.cfg.get("warmMic") and self.state == "idle" and (not self.warm or not self.warm.alive) and not self.cfg.get("warmHoldSecs", 0): self.ensure_warm() + async def source_watch(self): + """Plugin updated while running: exit when idle so the shell service relaunches the new code.""" + while not self.stopping: + await asyncio.sleep(5) + try: + changed = os.stat(SOURCE).st_mtime != SOURCE_MTIME + except OSError: + changed = False + if changed and self.state == "idle" and not (self.download_task and not self.download_task.done()): + log("daemon source changed; relaunching") + self.exit_code = EXIT_RELAUNCH + await self.shutdown() + return + async def shutdown(self): if self.stopping: return @@ -1843,6 +1884,7 @@ class Daemon: self.ensure_models() self.loop.create_task(self.hypr_events()) self.loop.create_task(self.warm_watch()) + self.loop.create_task(self.source_watch()) for s in (signal.SIGTERM, signal.SIGINT): self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown())) log(f"sttd {VERSION} listening on {SOCK}") @@ -1866,7 +1908,9 @@ def main(): sys.exit(3) # The socket is not removed on exit: the shell may already have started a # replacement daemon that listens on the same path. - asyncio.run(Daemon().run()) + daemon = Daemon() + asyncio.run(daemon.run()) + sys.exit(daemon.exit_code) if __name__ == "__main__": diff --git a/diagnostics/bluetooth/STARTUP-TIME.md b/diagnostics/bluetooth/STARTUP-TIME.md index 557c649..1e163d4 100644 --- a/diagnostics/bluetooth/STARTUP-TIME.md +++ b/diagnostics/bluetooth/STARTUP-TIME.md @@ -5,49 +5,48 @@ Measured on a Sony WH-1000XM5 over an Intel AX210, PipeWire 1.6.8, WirePlumber | | before | after | |---|---|---| -| mic link (eSCO) up | 0.7–1.0 s, sometimes never | 0.26–0.49 s | -| first real audio at the daemon | 1.5–1.7 s, or never | 1.06–1.43 s | -| "Listening" in the bar | 2.1 s on a timer, even with no audio | 1.19–1.43 s, only on real audio | +| mic link (eSCO) up | 0.7–1.0 s, sometimes never | 0.15–0.18 s | +| first real audio at the daemon | 1.5–1.7 s, or never | 0.82–0.86 s | +| "Listening" in the bar | 2.1 s on a timer, even with no audio | 0.86–0.94 s, only on real audio | + +"After" is with the daemon recording straight from `bluez_input.`; via +`camera-effects-mic` add ~0.25 s (its 250 ms poll and processing). ## What changed | Change | Where it lives | Gain | |---|---|---| | Daemon indicator: green on real audio only (RMS > 0.0005 or three consecutive non-silent chunks), no timer | `daemon/sttd.py` | the bar cannot claim to listen to a dead mic | +| Daemon asks for the headset profile itself at capture start (`pactl set-card-profile`), before WirePlumber notices the stream | `daemon/sttd.py` | link up ~0.1 s sooner (0.21–0.27 s → 0.15–0.18 s); WirePlumber still restores A2DP | +| Record from `bluez_input.` instead of `camera-effects-mic` | `~/.config/speech-to-text/config.json` | ~0.25 s sooner, no noise suppression on dictation | | WirePlumber profile-switch timeout 500 ms → 50 ms | `~/.local/share/wireplumber/scripts/device/autoswitch-bluetooth-profile.lua` | link up ~0.45 s sooner | | A2DP auto-connect rule narrowed to the XM5 | `~/.config/wireplumber/wireplumber.conf.d/bluetooth-a2dp-autoconnect.conf` | no 5 s pages of absent devices at login | | btusb driver patch `0002` | `/usr/lib/modules//updates/btusb.ko` | mic link no longer comes up silent when a page overlaps it | | PipeWire bluez5 patches `0001` + `0003` | `~/.local/lib/spa-0.2/bluez5/` via `SPA_PLUGIN_DIR` drop-ins | no stale-error dead takes; back-to-back takes work | -Only the first row is part of this repo. The rest are machine-level and are +Only the first two rows are part of this repo. The rest are machine-level and are described, with patches and measurements, in [HANDOFF.md](HANDOFF.md). -## Why ~0.9 s is the floor +## Why ~0.85 s is the floor ``` key press -0.00 s ─┬─ daemon starts pw-record on camera-effects-mic +0.00 s ─┬─ daemon asks for the headset profile, starts pw-record │ - │ Camera Effects notices the client (250 ms poll), - │ opens its helper stream, WirePlumber waits 50 ms, - │ then asks BlueZ for the headset profile - │ -0.30 s ─┼─ eSCO link up (the headset must leave sniff mode, - │ then the radio sets up the synchronous link: ~0.1 s) +0.07 s ─┼─ eSCO connect() reaches the kernel + │ headset leaves sniff mode, radio sets up the link +0.16 s ─┼─ eSCO link up │ │ headset sends encoded digital silence while its own - │ mic path starts and mSBC syncs: ~0.5–0.6 s + │ mic path starts and mSBC syncs: ~0.6 s │ (measured at the decoder; Linux cannot shorten it) │ -0.85 s ─┼─ first non-zero samples leave the headset - │ Camera Effects processing + 50 ms chunking -0.90 s ─┴─ first real audio reaches the daemon +0.80 s ─┼─ first non-zero samples leave the headset + │ decode + 50 ms chunking +0.85 s ─┴─ first real audio reaches the daemon ``` -What is left on the Linux side is the ~0.25 s before the profile switch is -even requested. The daemon could ask for the headset profile itself when -capture starts, but the WirePlumber autoswitch script only restores a profile -it switched, so the daemon would have to restore A2DP too. Not done. +Nothing measurable is left on the Linux side. The headset's silence after link-up and the eSCO setup are hardware. Words spoken in the first ~0.9 s are lost unless the microphone is already in diff --git a/plugin/Service.qml b/plugin/Service.qml index da80c67..9bf121e 100644 --- a/plugin/Service.qml +++ b/plugin/Service.qml @@ -105,6 +105,11 @@ Item { } onExited: function(code, status) { root.state = ({}) + if (code === 4) { // the daemon saw its own source change (plugin update) and asked to be relaunched + restartTimer.interval = 500 + restartTimer.restart() + return + } root.restarts += 1 if (code !== 3 && root.restarts >= 3) root.daemonError = "daemon keeps exiting (code " + code + ") — check the log" restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock