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:
parent
5ad4e92a73
commit
6448711f65
@ -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
|
||||||
|
|||||||
@ -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…")
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user