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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Xoc9DJCViR7dg9eKzzAcfQ
This commit is contained in:
2026-09-09 12:17:06 +01:00
parent 6f7f7ddd6c
commit b93327dc26
3 changed files with 69 additions and 21 deletions
+45 -1
View File
@@ -175,6 +175,9 @@ TERMINAL_CLASSES = {"alacritty", "kitty", "foot", "com.mitchellh.ghostty", "org.
LOG_FILE = os.path.join(RUNTIME, "daemon.log") 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. # 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") STT_CLI = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), "bin", "stt")
@@ -516,6 +519,28 @@ class Binds:
# recorder # 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: class Recorder:
def __init__(self, device): def __init__(self, device):
self.device = device self.device = device
@@ -1066,6 +1091,7 @@ class Daemon:
self.loop = None self.loop = None
self.stopping = False self.stopping = False
self.stop_event = None self.stop_event = None
self.exit_code = 0
# ---- state ---- # ---- state ----
def state_msg(self, full=False): def state_msg(self, full=False):
@@ -1331,6 +1357,7 @@ class Daemon:
self.warm.stop() self.warm.stop()
self.warm = None self.warm = None
self.rec = Recorder(self.cfg.get("device", "default")) self.rec = Recorder(self.cfg.get("device", "default"))
request_headset_profile(self.rec.device or "")
try: try:
self.rec.start() self.rec.start()
except (OSError, ValueError, TypeError) as e: 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): 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() 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): async def shutdown(self):
if self.stopping: if self.stopping:
return return
@@ -1843,6 +1884,7 @@ class Daemon:
self.ensure_models() self.ensure_models()
self.loop.create_task(self.hypr_events()) self.loop.create_task(self.hypr_events())
self.loop.create_task(self.warm_watch()) self.loop.create_task(self.warm_watch())
self.loop.create_task(self.source_watch())
for s in (signal.SIGTERM, signal.SIGINT): for s in (signal.SIGTERM, signal.SIGINT):
self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown())) self.loop.add_signal_handler(s, lambda: self.loop.create_task(self.shutdown()))
log(f"sttd {VERSION} listening on {SOCK}") log(f"sttd {VERSION} listening on {SOCK}")
@@ -1866,7 +1908,9 @@ def main():
sys.exit(3) sys.exit(3)
# The socket is not removed on exit: the shell may already have started a # The socket is not removed on exit: the shell may already have started a
# replacement daemon that listens on the same path. # 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__": if __name__ == "__main__":
+19 -20
View File
@@ -5,49 +5,48 @@ Measured on a Sony WH-1000XM5 over an Intel AX210, PipeWire 1.6.8, WirePlumber
| | before | after | | | before | after |
|---|---|---| |---|---|---|
| mic link (eSCO) up | 0.71.0 s, sometimes never | 0.260.49 s | | mic link (eSCO) up | 0.71.0 s, sometimes never | 0.150.18 s |
| first real audio at the daemon | 1.51.7 s, or never | 1.061.43 s | | first real audio at the daemon | 1.51.7 s, or never | 0.820.86 s |
| "Listening" in the bar | 2.1 s on a timer, even with no audio | 1.191.43 s, only on real audio | | "Listening" in the bar | 2.1 s on a timer, even with no audio | 0.860.94 s, only on real audio |
"After" is with the daemon recording straight from `bluez_input.<addr>`; via
`camera-effects-mic` add ~0.25 s (its 250 ms poll and processing).
## What changed ## What changed
| Change | Where it lives | Gain | | 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 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.210.27 s → 0.150.18 s); WirePlumber still restores A2DP |
| Record from `bluez_input.<addr>` 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 | | 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 | | 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/<kernel>/updates/btusb.ko` | mic link no longer comes up silent when a page overlaps it | | btusb driver patch `0002` | `/usr/lib/modules/<kernel>/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 | | 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). 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 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), 0.07 s ─┼─ eSCO connect() reaches the kernel
opens its helper stream, WirePlumber waits 50 ms, headset leaves sniff mode, radio sets up the link
│ then asks BlueZ for the headset profile 0.16 s ─┼─ eSCO link up
0.30 s ─┼─ eSCO link up (the headset must leave sniff mode,
│ then the radio sets up the synchronous link: ~0.1 s)
│ headset sends encoded digital silence while its own │ headset sends encoded digital silence while its own
│ mic path starts and mSBC syncs: ~0.50.6 s │ mic path starts and mSBC syncs: ~0.6 s
│ (measured at the decoder; Linux cannot shorten it) │ (measured at the decoder; Linux cannot shorten it)
0.85 s ─┼─ first non-zero samples leave the headset 0.80 s ─┼─ first non-zero samples leave the headset
Camera Effects processing + 50 ms chunking decode + 50 ms chunking
0.90 s ─┴─ first real audio reaches the daemon 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 Nothing measurable is left on the Linux side.
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.
The headset's silence after link-up and the eSCO setup are hardware. Words 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 spoken in the first ~0.9 s are lost unless the microphone is already in
+5
View File
@@ -105,6 +105,11 @@ Item {
} }
onExited: function(code, status) { onExited: function(code, status) {
root.state = ({}) 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 root.restarts += 1
if (code !== 3 && root.restarts >= 3) root.daemonError = "daemon keeps exiting (code " + code + ") — check the log" 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 restartTimer.interval = code === 3 ? 5000 : Math.min(10000, 1000 + root.restarts * 1000) // 3 = another instance holds the lock