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:
parent
1567202e1a
commit
1c64f19bce
@ -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
|
||||||
|
|||||||
@ -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"
|
||||||
|
|||||||
@ -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
|
try:
|
||||||
import sounddevice as sd
|
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):
|
def _loop(self) -> None:
|
||||||
level = float(np.sqrt(np.mean(np.square(indata)))) if indata.size else 0.0
|
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:
|
if self.on_level:
|
||||||
# Scale RMS (typically small) into a usable 0..1 range.
|
# Scale RMS (typically small) into a usable 0..1 range.
|
||||||
self.on_level(min(1.0, level * 12.0))
|
self.on_level(min(1.0, level * 12.0))
|
||||||
|
except Exception: # noqa: BLE001 - metering is eye-candy; never crash the app
|
||||||
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
|
|
||||||
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
|
||||||
|
if proc is not None and proc.poll() is None:
|
||||||
|
proc.terminate()
|
||||||
try:
|
try:
|
||||||
# PortAudio/ALSA spews thread-teardown noise to fd 2 here.
|
proc.wait(timeout=1.0)
|
||||||
with _quiet_c_stderr():
|
except subprocess.TimeoutExpired:
|
||||||
self._stream.stop()
|
proc.kill()
|
||||||
self._stream.close()
|
thread, self._thread = self._thread, None
|
||||||
finally:
|
if thread is not None:
|
||||||
self._stream = None
|
thread.join(timeout=1.0)
|
||||||
|
|||||||
@ -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:
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user