overlay: drive waveform + silence countdown via pw-record (fix PipeWire); app reports as "Blitztext" not __main__.py; Release 1.7.1

The live waveform and silence auto-stop countdown were driven by a level
meter that was the last user of sounddevice/PortAudio, which hangs opening
the default input on PipeWire systems — so both stayed blank on the hotkey
and wakeword paths alike. Rewrite LevelMeter to stream raw PCM from the same
recorder as the WAV path (pw-record/parecord/arecord) and RMS it; identical
API, scaling, and ~10 Hz cadence. Also fixes the Settings mic-level preview.

Set GLib prgname/application name to "Blitztext" before any window is
realized (and add StartupWMClass to the .desktop) so the taskbar and GNOME's
"… is not responding" dialog show the app name instead of "__main__.py",
without touching the `python -m blitztext` entry point.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
mARTin-B78 2026-06-08 08:39:37 +02:00
parent 1567202e1a
commit 1c64f19bce
6 changed files with 117 additions and 74 deletions

View File

@ -9,6 +9,26 @@ The version is defined in [`blitztext/__init__.py`](blitztext/__init__.py).
## [Unreleased] ## [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 ## [1.7.0] - 2026-06-07
### Added ### Added

View File

@ -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. (not in a container) so it can type into any application via xdotool.
""" """
__version__ = "1.7.0" __version__ = "1.7.1"

View File

@ -1,43 +1,39 @@
"""Audio helpers: enumerate input devices and a live input-level meter. """Audio helpers: enumerate input devices and a live input-level meter.
Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record Mic enumeration uses pactl (PipeWire/PulseAudio source names, which pw-record
and parecord accept via --target/-d). The level meter uses sounddevice to read and parecord accept via --target/-d). The level meter shells out to the same
the chosen input and report a 0..1 level to a callback. 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 from __future__ import annotations
import contextlib
import os
import shutil import shutil
import subprocess import subprocess
import sys
import threading import threading
from .recorder import detect_recorder
@contextlib.contextmanager # Raw-PCM (s16le, 16 kHz mono) variants of the recorders, streamed to stdout so
def _quiet_c_stderr(): # we can RMS each chunk directly. Mirrors recorder.py's WAV commands but emits
"""Silence chatter written directly to fd 2 by C libraries. # 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 # How to point each recorder at a specific pactl/pipewire source (mirrors
("pthread_join ... failed", "PaUnixThread_Terminate ... failed") straight to # recorder._DEVICE_FLAG; arecord uses ALSA names, so it stays on the default).
the underlying stderr file descriptor, which Python-level redirection can't _DEVICE_FLAG: dict[str, list[str]] = {
catch. We briefly point fd 2 at /dev/null around the offending call. "pw-record": ["--target"],
""" "parecord": ["-d"],
try: "arecord": [],
stderr_fd = sys.stderr.fileno() }
except (AttributeError, ValueError, OSError):
yield # No real stderr fd (already captured/redirected) — nothing to do. _CHUNK_BYTES = 3200 # 100 ms of 16 kHz, 16-bit, mono → ~10 Hz level updates
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)
def list_mics() -> list[tuple[str, str]]: def list_mics() -> list[tuple[str, str]]:
@ -68,58 +64,76 @@ def list_mics() -> list[tuple[str, str]]:
class LevelMeter: 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): Uses pw-record/parecord/arecord (the same recorders as the WAV recorder)
self.device = device or None 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.on_level = on_level
self._stream = None self._recorder = recorder
self._lock = threading.Lock() 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: 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: try:
with _quiet_c_stderr(): recorder = detect_recorder(self._recorder)
self._stream = sd.InputStream( except RuntimeError:
samplerate=16000, channels=1, dtype="float32", return False
blocksize=1600, device=self._resolve_device(), callback=_cb, if recorder not in _METER_ARGV:
)
self._stream.start()
return True
except Exception: # noqa: BLE001 - device may be busy/unavailable
self._stream = None
return False 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: 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()): def _loop(self) -> None:
if d["max_input_channels"] > 0 and self.device in d["name"]: import numpy as np
return i
except Exception: # noqa: BLE001 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 pass
return None
def stop(self) -> None: def stop(self) -> None:
with self._lock: self._stop.set()
if self._stream is not None: proc, self._proc = self._proc, None
try: if proc is not None and proc.poll() is None:
# PortAudio/ALSA spews thread-teardown noise to fd 2 here. proc.terminate()
with _quiet_c_stderr(): try:
self._stream.stop() proc.wait(timeout=1.0)
self._stream.close() except subprocess.TimeoutExpired:
finally: proc.kill()
self._stream = None thread, self._thread = self._thread, None
if thread is not None:
thread.join(timeout=1.0)

View File

@ -196,7 +196,7 @@ class Daemon:
elif self.countdown_cb: elif self.countdown_cb:
self.countdown_cb(None, silence) 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() self._vad_meter.start()
def _vad_stop(self) -> None: def _vad_stop(self) -> None:
@ -213,7 +213,7 @@ class Daemon:
if not self.level_cb: if not self.level_cb:
return return
from . import audio 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() self._ov_meter.start()
def _ov_meter_stop(self) -> None: def _ov_meter_stop(self) -> None:

View File

@ -408,6 +408,14 @@ class App:
def run_gui(tray_mode: bool = False) -> int: 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() cfg = load()
App(cfg, tray_mode=tray_mode).run() App(cfg, tray_mode=tray_mode).run()
return 0 return 0

View File

@ -6,6 +6,7 @@ Comment=Linux voice dictation with local, remote, and realtime STT engines
Exec=blitztext tray Exec=blitztext tray
Icon=blitztext Icon=blitztext
Terminal=false Terminal=false
StartupWMClass=blitztext
Categories=Utility;AudioVideo;Accessibility; Categories=Utility;AudioVideo;Accessibility;
Keywords=dictation;speech;voice;transcription;whisper;stt;riva;nim; Keywords=dictation;speech;voice;transcription;whisper;stt;riva;nim;
StartupNotify=false StartupNotify=false