CancelWatcher accumulates raw PCM from the VAD LevelMeter (via new on_chunk callback) and runs a fast beam_size=1 transcription check every ~0.6s. When a cancel keyword is detected it immediately calls cancel_dictation() without waiting for the silence timer to expire or a full transcription to complete. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
143 lines
5.3 KiB
Python
143 lines
5.3 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", on_chunk=None):
|
|
self.device = device or ""
|
|
self.on_level = on_level
|
|
self.on_chunk = on_chunk # optional: called with raw s16le PCM bytes each chunk
|
|
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
|
|
if self.on_chunk:
|
|
self.on_chunk(chunk)
|
|
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)
|