blitztext-app-linux/linux/blitztext/recorder.py
mARTin-B78 44ca0feabd Redesign Settings: preset/engine managers, mic + level meter, autostart
New GTK settings with a dropdown+editor pattern across four tabs:
- Presets: select/add/delete prompt presets; edit name, keywords, hotkey, mode,
  per-preset model/temperature, and a clearly-bordered prompt textfield.
- Engines: STT and LLM engine managers with green/red online-offline status
  dots, add/edit/delete, active selector, and an STT record-and-benchmark Test.
- Input: input scheme + keys + quality-gate settings.
- General: microphone picker with a live sounddevice level meter, output,
  language, notifications, launch-on-login toggle, and local-Whisper settings.

Adds audio.py (mic enumeration via pactl + LevelMeter) and autostart.py
(~/.config/autostart entry). Recorder + daemon honour the selected mic.
Requirements gain sounddevice + tomli-w.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 23:13:36 +02:00

75 lines
2.6 KiB
Python

"""Microphone recording by shelling out to a system recorder.
Records 16 kHz mono WAV (what Whisper wants) using whichever recorder is
available — no Python audio bindings or device configuration required.
"""
from __future__ import annotations
import shutil
import signal
import subprocess
import tempfile
from pathlib import Path
# Ordered by preference. Each maps a recorder name to an argv builder.
_RECORDERS: dict[str, list[str]] = {
"pw-record": ["pw-record", "--rate", "16000", "--channels", "1", "--format", "s16"],
"parecord": ["parecord", "--rate=16000", "--channels=1", "--format=s16le", "--file-format=wav"],
"arecord": ["arecord", "-q", "-f", "S16_LE", "-r", "16000", "-c", "1", "-t", "wav"],
}
# How to point each recorder at a specific pactl/pipewire source.
_DEVICE_FLAG: dict[str, list[str]] = {
"pw-record": ["--target"],
"parecord": ["-d"],
"arecord": [], # ALSA names differ from pactl; fall back to default device
}
def detect_recorder(preference: str = "auto") -> str:
if preference != "auto":
if shutil.which(preference):
return preference
raise RuntimeError(f"Configured recorder '{preference}' not found on PATH.")
for name in _RECORDERS:
if shutil.which(name):
return name
raise RuntimeError("No recorder found (need one of: pw-record, parecord, arecord).")
class Recording:
"""A single in-progress recording. Start on construction, call stop()."""
def __init__(self, recorder: str, device: str = ""):
self._tmp = Path(tempfile.mkstemp(prefix="blitztext-", suffix=".wav")[1])
argv = list(_RECORDERS[recorder])
flag = _DEVICE_FLAG.get(recorder, [])
if device and flag:
argv += flag + [device]
argv += [str(self._tmp)]
# arecord writes to the file given as a positional arg; the others too.
self._proc = subprocess.Popen(
argv,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
def stop(self) -> Path:
"""Stop recording, finalize the WAV, and return its path."""
if self._proc.poll() is None:
# SIGINT lets pw-record/arecord flush the WAV header cleanly.
self._proc.send_signal(signal.SIGINT)
try:
self._proc.wait(timeout=5)
except subprocess.TimeoutExpired:
self._proc.terminate()
self._proc.wait(timeout=5)
return self._tmp
def discard(self) -> None:
if self._proc.poll() is None:
self._proc.terminate()
self._proc.wait(timeout=5)
self._tmp.unlink(missing_ok=True)