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:
+45
-1
@@ -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__":
|
||||
|
||||
@@ -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.<addr>`; 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.<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 |
|
||||
| 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 |
|
||||
| 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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user