Stop notification storms: transient notifications, single instance, no empty-result alerts

- 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 <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-06 10:42:13 +02:00
parent 5ad4e92a73
commit 6448711f65
3 changed files with 41 additions and 15 deletions

View File

@ -13,6 +13,23 @@ from pathlib import Path
from . import __version__ from . import __version__
from .config import CONFIG_PATH, ensure_default, load 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: def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(prog="blitztext", description="Native dictation for Linux.") 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)) print(ensure_default(CONFIG_PATH))
return 0 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"): if cmd in ("gui", "tray"):
ensure_default(CONFIG_PATH) ensure_default(CONFIG_PATH)
from .gtkui import run_gui from .gtkui import run_gui

View File

@ -319,7 +319,7 @@ class Daemon:
min_seconds=self.cfg.min_speech_seconds, min_seconds=self.cfg.min_speech_seconds,
silence_rms=self.cfg.silence_rms): silence_rms=self.cfg.silence_rms):
self._emit("idle", label, "Too quiet") self._emit("idle", label, "Too quiet")
self._notify("Nothing heard", "No speech detected.", "low") log("Nothing heard — clip too quiet/short.")
return return
self._emit("busy", label, "Transcribing…") self._emit("busy", label, "Transcribing…")
@ -337,7 +337,7 @@ class Daemon:
text = quality.clean(text, strip_trailing_punctuation=self.cfg.strip_trailing_punctuation) 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)): if not text or (self.cfg.reject_hallucinations and quality.is_hallucination(text, duration)):
self._emit("idle", label, "No speech detected") self._emit("idle", label, "No speech detected")
self._notify("Nothing heard", "No speech detected.", "low") log("Nothing heard — no speech detected.")
return return
# Voice routing: pick the preset from a spoken keyword, strip it. # 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 target and target.mode == "rewrite" and target.prompt:
if not text: if not text:
self._emit("idle", label, "Only a keyword heard") 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 return
self._emit("busy", label, "Rewriting…") self._emit("busy", label, "Rewriting…")
self._notify(f"{label}", "Rewriting…") self._notify(f"{label}", "Rewriting…")

View File

@ -12,20 +12,23 @@ _APP = "Blitztext"
_REPLACE_ID = "99317" _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: if enabled and _HAVE_NOTIFY:
try: try:
subprocess.run( args = [
[
"notify-send", "notify-send",
"--app-name", _APP, "--app-name", _APP,
"--urgency", urgency, "--urgency", urgency,
"--expire-time", "2500",
# Replace the previous bubble instead of stacking.
"--hint", f"string:x-canonical-private-synchronous:{_REPLACE_ID}", "--hint", f"string:x-canonical-private-synchronous:{_REPLACE_ID}",
title, ]
body, if transient:
], # Don't keep it in the notification log / lock screen.
check=False, args += ["--hint", "boolean:transient:true"]
) args += [title, body]
subprocess.run(args, check=False)
return return
except OSError: except OSError:
pass pass