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>
140 lines
5.1 KiB
Python
140 lines
5.1 KiB
Python
"""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 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 shutil
|
|
import subprocess
|
|
import threading
|
|
|
|
from .recorder import detect_recorder
|
|
|
|
# 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"],
|
|
}
|
|
|
|
# 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]]:
|
|
"""Return [(source_name, friendly_label)] for real input sources.
|
|
|
|
The first entry is always the system default ("", "Default device").
|
|
Monitor sources (loopback of outputs) are excluded.
|
|
"""
|
|
mics: list[tuple[str, str]] = [("", "Default device")]
|
|
if not shutil.which("pactl"):
|
|
return mics
|
|
try:
|
|
out = subprocess.run(
|
|
["pactl", "list", "short", "sources"], capture_output=True, text=True, check=True
|
|
).stdout
|
|
except (OSError, subprocess.CalledProcessError):
|
|
return mics
|
|
for line in out.splitlines():
|
|
parts = line.split("\t")
|
|
if len(parts) < 2:
|
|
continue
|
|
name = parts[1]
|
|
if name.endswith(".monitor"):
|
|
continue
|
|
label = name.replace("alsa_input.", "").replace("-", " ")
|
|
mics.append((name, label))
|
|
return mics
|
|
|
|
|
|
class LevelMeter:
|
|
"""Stream mic audio via a system recorder and call `on_level(0..1)` ~10x/s.
|
|
|
|
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._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:
|
|
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 _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
|
|
|
|
def stop(self) -> 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)
|