From 6448711f6599832a80f5c93746cef3b990712aaf Mon Sep 17 00:00:00 2001 From: mARTin-B78 Date: Sat, 6 Jun 2026 10:42:13 +0200 Subject: [PATCH] Stop notification storms: transient notifications, single instance, no empty-result alerts MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - notify-send notifications are now transient (boolean:transient:true) with a 2.5s expiry, so they no longer pile up in the message tray / lock screen. - Single-instance guard (abstract unix socket) in the entry point: a second `tray`/`gui`/`run` launch exits instead of starting a duplicate daemon — the duplicate caused two wakeword listeners + two recorders. - Empty/quiet/keyword-only results now only log, instead of firing a desktop notification on every (often false) wakeword trigger. Co-Authored-By: Claude Opus 4.8 --- linux/blitztext/blitztext.py | 23 +++++++++++++++++++++++ linux/blitztext/daemon.py | 6 +++--- linux/blitztext/notify.py | 27 +++++++++++++++------------ 3 files changed, 41 insertions(+), 15 deletions(-) diff --git a/linux/blitztext/blitztext.py b/linux/blitztext/blitztext.py index b21ed91..9551ec6 100644 --- a/linux/blitztext/blitztext.py +++ b/linux/blitztext/blitztext.py @@ -13,6 +13,23 @@ from pathlib import Path from . import __version__ from .config import CONFIG_PATH, ensure_default, load +# Held for the process lifetime to enforce a single running instance. +_SINGLE_INSTANCE_SOCK = None + + +def _acquire_single_instance() -> bool: + """Bind an abstract unix socket; False if another instance already holds it.""" + global _SINGLE_INSTANCE_SOCK + import socket + + sock = socket.socket(socket.AF_UNIX, socket.SOCK_DGRAM) + try: + sock.bind("\0blitztext-single-instance") # abstract namespace (auto-freed on exit) + except OSError: + return False + _SINGLE_INSTANCE_SOCK = sock + return True + def main(argv: list[str] | None = None) -> int: parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.") @@ -36,6 +53,12 @@ def main(argv: list[str] | None = None) -> int: print(ensure_default(CONFIG_PATH)) return 0 + # Only one live daemon/tray/gui at a time — prevents duplicate wakeword + # listeners and recorders (which caused notification storms). + if cmd in ("gui", "tray", "run") and not _acquire_single_instance(): + print("[blitztext] already running — not starting a second instance.", file=sys.stderr) + return 0 + if cmd in ("gui", "tray"): ensure_default(CONFIG_PATH) from .gtkui import run_gui diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 14cc3e2..45d1dab 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -319,7 +319,7 @@ class Daemon: min_seconds=self.cfg.min_speech_seconds, silence_rms=self.cfg.silence_rms): self._emit("idle", label, "Too quiet") - self._notify("Nothing heard", "No speech detected.", "low") + log("Nothing heard — clip too quiet/short.") return self._emit("busy", label, "Transcribing…") @@ -337,7 +337,7 @@ class Daemon: text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation) if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)): self._emit("idle", label, "No speech detected") - self._notify("Nothing heard", "No speech detected.", "low") + log("Nothing heard — no speech detected.") return # Voice routing: pick the preset from a spoken keyword, strip it. @@ -355,7 +355,7 @@ class Daemon: if target and target.mode == "rewrite" and target.prompt: if not text: self._emit("idle", label, "Only a keyword heard") - self._notify("Nothing to do", "Only the keyword was heard.", "low") + log("Only the keyword was heard — nothing to type.") return self._emit("busy", label, "Rewriting…") self._notify(f"⌛ {label}", "Rewriting…") diff --git a/linux/blitztext/notify.py b/linux/blitztext/notify.py index 8e8c4d6..40d9c64 100644 --- a/linux/blitztext/notify.py +++ b/linux/blitztext/notify.py @@ -12,20 +12,23 @@ _APP = "Blitztext" _REPLACE_ID = "99317" -def notify(title: str, body: str = "", *, urgency: str = "normal", enabled: bool = True) -> None: +def notify(title: str, body: str = "", *, urgency: str = "normal", enabled: bool = True, + transient: bool = True) -> None: if enabled and _HAVE_NOTIFY: try: - subprocess.run( - [ - "notify-send", - "--app-name", _APP, - "--urgency", urgency, - "--hint", f"string:x-canonical-private-synchronous:{_REPLACE_ID}", - title, - body, - ], - check=False, - ) + args = [ + "notify-send", + "--app-name", _APP, + "--urgency", urgency, + "--expire-time", "2500", + # Replace the previous bubble instead of stacking. + "--hint", f"string:x-canonical-private-synchronous:{_REPLACE_ID}", + ] + if transient: + # Don't keep it in the notification log / lock screen. + args += ["--hint", "boolean:transient:true"] + args += [title, body] + subprocess.run(args, check=False) return except OSError: pass