"""Raw PCM capture through PipeWire (pw-record) with a live level meter.""" import array import json import math import shutil import signal import subprocess import tempfile import threading import wave from PyQt6.QtCore import QObject, pyqtSignal from i18n import t RATE = 16000 CHANNELS = 1 SAMPLE_WIDTH = 2 # s16 CHUNK_FRAMES = 1024 CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS MIN_FRAMES = int(RATE * 0.25) class Recorder(QObject): """Runs pw-record as a child process and reads raw PCM from its stdout.""" level = pyqtSignal(float) # 0.0 - 1.0, for the waveform stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS failed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) self._proc = None self._thread = None self._buffer = bytearray() self._rms = [] self._cancelled = False self._lock = threading.Lock() @property def active(self): return self._thread is not None and self._thread.is_alive() def start(self, target="", max_seconds=300): if self.active: return if not shutil.which("pw-record"): self.failed.emit(t("pw-record not found. Is pipewire-audio installed?")) return cmd = [ "pw-record", "--raw", f"--rate={RATE}", f"--channels={CHANNELS}", "--format=s16", ] if target: cmd.append(f"--target={target}") cmd.append("-") try: self._proc = subprocess.Popen( cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0 ) except OSError as exc: self.failed.emit(t("Could not start recording: {error}", error=exc)) return self._buffer = bytearray() self._rms = [] self._cancelled = False self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS) self._thread = threading.Thread(target=self._pump, daemon=True) self._thread.start() def _pump(self): stdout = self._proc.stdout try: while True: chunk = stdout.read(CHUNK_BYTES) if not chunk: break peak, rms = chunk_levels(chunk) with self._lock: self._buffer.extend(chunk) self._rms.append(rms) too_long = len(self._buffer) >= self._max_bytes self.level.emit(peak) if too_long: self._terminate() break except (OSError, ValueError): pass def _terminate(self): proc = self._proc if proc and proc.poll() is None: try: proc.send_signal(signal.SIGINT) proc.wait(timeout=1.5) except (subprocess.TimeoutExpired, OSError): try: proc.kill() except OSError: pass def cancel(self): self._cancelled = True self._terminate() if self._thread: self._thread.join(timeout=2) self._thread = None self._proc = None with self._lock: self._buffer = bytearray() def stop(self): """End the recording and write the WAV file.""" if not self._proc: return self._terminate() if self._thread: self._thread.join(timeout=2) self._thread = None self._proc = None with self._lock: pcm = bytes(self._buffer) rms = list(self._rms) self._buffer = bytearray() if self._cancelled: return frames = len(pcm) // (SAMPLE_WIDTH * CHANNELS) if frames < MIN_FRAMES: # a stray keypress, not speech self.failed.emit(t("Recording too short — speak for at least 0.3 s")) return path = write_wav(pcm) self.stopped.emit(path, frames / RATE, rms) def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH): fd, path = tempfile.mkstemp(prefix="dikte-", suffix=".wav") with open(fd, "wb") as raw, wave.open(raw, "wb") as wav: wav.setnchannels(channels) wav.setsampwidth(width) wav.setframerate(rate) wav.writeframes(pcm) return path def chunk_levels(chunk): """(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check.""" samples = array.array("h") usable = len(chunk) - (len(chunk) % 2) if usable <= 0: return 0.0, 0.0 samples.frombytes(chunk[:usable]) peak = max(abs(min(samples)), abs(max(samples))) / 32768.0 rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0 return min(1.0, peak), min(1.0, rms) def list_sources(): """[(name, description)] for every real input source.""" if not shutil.which("pactl"): return [] try: out = subprocess.run( ["pactl", "-f", "json", "list", "sources"], capture_output=True, text=True, timeout=5, check=True, ).stdout data = json.loads(out) except (subprocess.SubprocessError, OSError, json.JSONDecodeError): return [] return [ (src.get("name", ""), src.get("description") or src.get("name", "")) for src in data if not src.get("name", "").endswith(".monitor") ]