mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
The screenshots were downscaled to 430 px wide, which made the UI text blurry. Restore them at native 1292 px as lossless WebP, which is also half the size of the original PNGs (72 KB against 155 KB for the largest). Rewrite every em dash in prose, comments, docstrings and interface strings as ordinary punctuation.
185 lines
5.4 KiB
Python
185 lines
5.4 KiB
Python
"""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")
|
|
]
|