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]
## [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

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.
"""
__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.
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
try:
recorder = detect_recorder(self._recorder)
except RuntimeError:
return False
if recorder not in _METER_ARGV:
return False
try:
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
def _cb(indata, _frames, _time, _status):
level = float(np.sqrt(np.mean(np.square(indata)))) if indata.size else 0.0
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))
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
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
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
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:
self._stop.set()
proc, self._proc = self._proc, None
if proc is not None and proc.poll() is None:
proc.terminate()
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
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)

View File

@ -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:

View File

@ -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

View File

@ -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