diff --git a/linux/CHANGELOG.md b/linux/CHANGELOG.md index 42829bc..6696876 100644 --- a/linux/CHANGELOG.md +++ b/linux/CHANGELOG.md @@ -9,6 +9,26 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py). ## [Unreleased] +## [1.7.1] - 2026-06-08 + +### Fixed +- **Overlay waveform and silence countdown ring never appeared** on systems + where PortAudio/`sounddevice` can't open the default input — notably PipeWire + boxes, where opening an input stream simply hangs. Both the live waveform and + the auto-stop countdown are driven by a single level meter, which was the only + part of the app still using `sounddevice` (everything else records via + `pw-record`). The meter now streams raw PCM from the **same system recorder as + the WAV recorder** (`pw-record`/`parecord`/`arecord`) and computes the level + itself, so it works wherever recording works — on both the hotkey and + hands-free (wakeword) paths, plus the mic-level preview in Settings. No more + PortAudio dependency for metering. +- **App reported itself as "`__main__.py`"** in the taskbar and in GNOME's + "… is not responding" dialog. Launched via `python -m blitztext`, GTK's default + program name is `argv[0]`'s basename. It now sets `prgname`/application name to + **Blitztext** before any window is realized (and the desktop file gains + `StartupWMClass=blitztext` for the .desktop match + icon), without touching the + `-m blitztext` entry point. + ## [1.7.0] - 2026-06-07 ### Added diff --git a/linux/blitztext/__init__.py b/linux/blitztext/__init__.py index a22ecd3..69399e6 100644 --- a/linux/blitztext/__init__.py +++ b/linux/blitztext/__init__.py @@ -6,4 +6,4 @@ counterpart to the macOS Blitztext menu bar app: it runs natively on the host (not in a container) so it can type into any application via xdotool. """ -__version__ = "1.7.0" +__version__ = "1.7.1" diff --git a/linux/blitztext/audio.py b/linux/blitztext/audio.py index eba045e..ddc800d 100644 --- a/linux/blitztext/audio.py +++ b/linux/blitztext/audio.py @@ -1,43 +1,39 @@ """Audio helpers: enumerate input devices and a live input-level meter. Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record -and parecord accept via --target/-d). The level meter uses sounddevice to read -the chosen input and report a 0..1 level to a callback. +and parecord accept via --target/-d). The level meter shells out to the same +system recorder the app uses (pw-record/parecord/arecord), reading raw PCM from +its stdout and reporting a 0..1 level to a callback — no Python audio binding, +so it works wherever the recorder does (PortAudio/sounddevice can't open the +default input on some PipeWire systems). """ from __future__ import annotations -import contextlib -import os import shutil import subprocess -import sys import threading +from .recorder import detect_recorder -@contextlib.contextmanager -def _quiet_c_stderr(): - """Silence chatter written directly to fd 2 by C libraries. +# Raw-PCM (s16le, 16 kHz mono) variants of the recorders, streamed to stdout so +# we can RMS each chunk directly. Mirrors recorder.py's WAV commands but emits +# headerless PCM. pw-record/parecord default to stdout; arecord uses "-t raw". +_METER_ARGV: dict[str, list[str]] = { + "pw-record": ["pw-record", "--rate=16000", "--channels=1", "--format=s16", "-"], + "parecord": ["parecord", "--rate=16000", "--channels=1", "--format=s16le"], + "arecord": ["arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "raw"], +} - PortAudio/ALSA print harmless thread-teardown noise - ("pthread_join ... failed", "PaUnixThread_Terminate ... failed") straight to - the underlying stderr file descriptor, which Python-level redirection can't - catch. We briefly point fd 2 at /dev/null around the offending call. - """ - try: - stderr_fd = sys.stderr.fileno() - except (AttributeError, ValueError, OSError): - yield # No real stderr fd (already captured/redirected) — nothing to do. - return - saved_fd = os.dup(stderr_fd) - devnull_fd = os.open(os.devnull, os.O_WRONLY) - try: - os.dup2(devnull_fd, stderr_fd) - yield - finally: - os.dup2(saved_fd, stderr_fd) - os.close(devnull_fd) - os.close(saved_fd) +# How to point each recorder at a specific pactl/pipewire source (mirrors +# recorder._DEVICE_FLAG; arecord uses ALSA names, so it stays on the default). +_DEVICE_FLAG: dict[str, list[str]] = { + "pw-record": ["--target"], + "parecord": ["-d"], + "arecord": [], +} + +_CHUNK_BYTES = 3200 # 100 ms of 16 kHz, 16-bit, mono → ~10 Hz level updates def list_mics() -> list[tuple[str, str]]: @@ -68,58 +64,76 @@ def list_mics() -> list[tuple[str, str]]: class LevelMeter: - """Open the given input device and call `on_level(0..1)` periodically.""" + """Stream mic audio via a system recorder and call `on_level(0..1)` ~10x/s. - def __init__(self, device: str = "", on_level=None): - self.device = device or None + Uses pw-record/parecord/arecord (the same recorders as the WAV recorder) + rather than a Python audio binding, so it works on PipeWire boxes where + PortAudio can't open the default input. Best-effort: ``start()`` returns + False if no recorder is available or the device can't be opened. + """ + + def __init__(self, device: str = "", on_level=None, recorder: str = "auto"): + self.device = device or "" self.on_level = on_level - self._stream = None - self._lock = threading.Lock() + self._recorder = recorder + self._proc: subprocess.Popen | None = None + self._thread: threading.Thread | None = None + self._stop = threading.Event() + + def _argv(self, recorder: str) -> list[str]: + argv = list(_METER_ARGV[recorder]) + flag = _DEVICE_FLAG.get(recorder, []) + if self.device and flag: + argv += flag + [self.device] + return argv def start(self) -> bool: - import numpy as np - import sounddevice as sd - - def _cb(indata, _frames, _time, _status): - level = float(np.sqrt(np.mean(np.square(indata)))) if indata.size else 0.0 - if self.on_level: - # Scale RMS (typically small) into a usable 0..1 range. - self.on_level(min(1.0, level * 12.0)) - try: - with _quiet_c_stderr(): - self._stream = sd.InputStream( - samplerate=16000, channels=1, dtype="float32", - blocksize=1600, device=self._resolve_device(), callback=_cb, - ) - self._stream.start() - return True - except Exception: # noqa: BLE001 - device may be busy/unavailable - self._stream = None + recorder = detect_recorder(self._recorder) + except RuntimeError: + return False + if recorder not in _METER_ARGV: return False - - def _resolve_device(self): - # sounddevice wants an index/name it knows; pactl names rarely match, so - # fall back to the default input when the name isn't resolvable. - if not self.device: - return None try: - import sounddevice as sd + self._proc = subprocess.Popen( + self._argv(recorder), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL + ) + except OSError: + self._proc = None + return False + self._stop.clear() + self._thread = threading.Thread(target=self._loop, daemon=True, name="LevelMeter") + self._thread.start() + return True - for i, d in enumerate(sd.query_devices()): - if d["max_input_channels"] > 0 and self.device in d["name"]: - return i - except Exception: # noqa: BLE001 + def _loop(self) -> None: + import numpy as np + + proc = self._proc + if proc is None or proc.stdout is None: + return + try: + while not self._stop.is_set() and proc.poll() is None: + chunk = proc.stdout.read(_CHUNK_BYTES) + if not chunk: + break + samples = np.frombuffer(chunk, dtype=np.int16).astype(np.float32) / 32768.0 + level = float(np.sqrt(np.mean(np.square(samples)))) if samples.size else 0.0 + if self.on_level: + # Scale RMS (typically small) into a usable 0..1 range. + self.on_level(min(1.0, level * 12.0)) + except Exception: # noqa: BLE001 - metering is eye-candy; never crash the app pass - return None def stop(self) -> None: - with self._lock: - if self._stream is not None: - try: - # PortAudio/ALSA spews thread-teardown noise to fd 2 here. - with _quiet_c_stderr(): - self._stream.stop() - self._stream.close() - finally: - self._stream = None + self._stop.set() + proc, self._proc = self._proc, None + if proc is not None and proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=1.0) + except subprocess.TimeoutExpired: + proc.kill() + thread, self._thread = self._thread, None + if thread is not None: + thread.join(timeout=1.0) diff --git a/linux/blitztext/daemon.py b/linux/blitztext/daemon.py index 0e79c2e..a8701f4 100644 --- a/linux/blitztext/daemon.py +++ b/linux/blitztext/daemon.py @@ -196,7 +196,7 @@ class Daemon: elif self.countdown_cb: self.countdown_cb(None, silence) - self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level) + self._vad_meter = audio.LevelMeter(self.cfg.mic, on_level=on_level, recorder=self.recorder_name) self._vad_meter.start() def _vad_stop(self) -> None: @@ -213,7 +213,7 @@ class Daemon: if not self.level_cb: return from . import audio - self._ov_meter = audio.LevelMeter(self.cfg.mic, on_level=self.level_cb) + self._ov_meter = audio.LevelMeter(self.cfg.mic, on_level=self.level_cb, recorder=self.recorder_name) self._ov_meter.start() def _ov_meter_stop(self) -> None: diff --git a/linux/blitztext/gtkui.py b/linux/blitztext/gtkui.py index 150a931..61372a4 100644 --- a/linux/blitztext/gtkui.py +++ b/linux/blitztext/gtkui.py @@ -408,6 +408,14 @@ class App: def run_gui(tray_mode: bool = False) -> int: + # Identify to the window manager as "blitztext" rather than the Python entry + # point's filename. Launched via `python -m blitztext`, GTK's default program + # name is argv[0]'s basename ("__main__.py"), which is what shows in the + # taskbar and in GNOME's "… is not responding" dialog. Setting it here (before + # any window is realized) gives every window the app's real name + .desktop + # match, without touching the `-m blitztext` entry point. + GLib.set_prgname("blitztext") + GLib.set_application_name("Blitztext") cfg = load() App(cfg, tray_mode=tray_mode).run() return 0 diff --git a/linux/packaging/blitztext.desktop b/linux/packaging/blitztext.desktop index 5e0a440..0dac1e5 100644 --- a/linux/packaging/blitztext.desktop +++ b/linux/packaging/blitztext.desktop @@ -6,6 +6,7 @@ Comment=Linux voice dictation with local, remote, and realtime STT engines Exec=blitztext tray Icon=blitztext Terminal=false +StartupWMClass=blitztext Categories=Utility;AudioVideo;Accessibility; Keywords=dictation;speech;voice;transcription;whisper;stt;riva;nim; StartupNotify=false