mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Keep the recorder alive through the ways a capture dies
Four holes in one class, found by a review the last one paid for. The capture's stderr went to a pipe nobody drained, so a chatty ffmpeg could fill it and freeze the recording; it goes to a file now, the way the meeting recorder always did. A capture dying with audio already buffered ended the pump in silence, the clock counting over a dead microphone; there is a died signal now, and the application transcribes what was caught. A pump that outlived its two-second join could write stale audio into the next recording and kill the next recording's process; each run now owns its objects and a token. Short pipe reads were each billed a whole chunk in the silence math, overstating speech; the pump reads exact chunks, as meetings do. Smaller ones alongside: write_wav failing no longer strands the state machine; a deliberate stop no longer reports ffmpeg's interrupt code as "Nothing was recorded: ffmpeg -> 255"; kills are reaped so no message says "exit code None"; the pw-record probe is paid once per process; the RMS loop uses sumprod where Python has it; the pactl and pw-record listings decode as the UTF-8 they are. paths gains the NO_WINDOW constant this module re-exports, the one spelling every subprocess site after it shares. Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
co-authored by
Claude Fable 5
parent
6f601ab969
commit
420cda9376
+157
-50
@@ -31,11 +31,21 @@ import wave
|
|||||||
|
|
||||||
from PyQt6.QtCore import QObject, pyqtSignal
|
from PyQt6.QtCore import QObject, pyqtSignal
|
||||||
|
|
||||||
|
from . import paths
|
||||||
from .i18n import t
|
from .i18n import t
|
||||||
|
|
||||||
# Console programs started from a windowless process would otherwise each open
|
# Squaring a chunk sample by sample in Python is the most expensive thing the
|
||||||
# a console window of their own on Windows.
|
# level meter does, and it does it for every chunk of every recording. sumprod
|
||||||
NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0) if sys.platform == "win32" else 0
|
# stays in C for the whole sum; it arrived in 3.12 and the floor here is 3.11,
|
||||||
|
# so the plain loop remains as the fallback. Both produce the same integer.
|
||||||
|
try:
|
||||||
|
from math import sumprod
|
||||||
|
except ImportError:
|
||||||
|
sumprod = None
|
||||||
|
|
||||||
|
# See paths.NO_WINDOW; re-exported here because this module's callers and
|
||||||
|
# tests have always read it under this name.
|
||||||
|
NO_WINDOW = paths.NO_WINDOW
|
||||||
|
|
||||||
RATE = 16000
|
RATE = 16000
|
||||||
CHANNELS = 1
|
CHANNELS = 1
|
||||||
@@ -79,12 +89,15 @@ class Recorder(QObject):
|
|||||||
|
|
||||||
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
||||||
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
||||||
|
died = pyqtSignal() # the capture quit mid-recording
|
||||||
failed = pyqtSignal(str)
|
failed = pyqtSignal(str)
|
||||||
|
|
||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._proc = None
|
self._proc = None
|
||||||
self._thread = None
|
self._thread = None
|
||||||
|
self._log = None
|
||||||
|
self._run = None
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
self._rms = []
|
self._rms = []
|
||||||
self._cancelled = False
|
self._cancelled = False
|
||||||
@@ -126,12 +139,17 @@ class Recorder(QObject):
|
|||||||
self.failed.emit(t(sound().missing))
|
self.failed.emit(t(sound().missing))
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# The recorder keeps talking to stderr for as long as it runs; a pipe
|
||||||
|
# nobody drains would eventually block it, so it writes to a file.
|
||||||
|
self._drop_log()
|
||||||
|
self._log = tempfile.TemporaryFile()
|
||||||
try:
|
try:
|
||||||
self._proc = subprocess.Popen(
|
self._proc = subprocess.Popen(
|
||||||
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0,
|
cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0,
|
||||||
creationflags=NO_WINDOW,
|
creationflags=NO_WINDOW,
|
||||||
)
|
)
|
||||||
except OSError as exc:
|
except OSError as exc:
|
||||||
|
self._drop_log()
|
||||||
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -141,15 +159,21 @@ class Recorder(QObject):
|
|||||||
self._stopping = False
|
self._stopping = False
|
||||||
self._paused = False
|
self._paused = False
|
||||||
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
||||||
self._thread = threading.Thread(target=self._pump, daemon=True)
|
# The pump is handed this run's objects rather than reading them off
|
||||||
|
# self, and a token to say whose run it still is: a pump that outlives
|
||||||
|
# its 2 s join must not touch the recording that comes after it.
|
||||||
|
self._run = object()
|
||||||
|
self._thread = threading.Thread(
|
||||||
|
target=self._pump, daemon=True,
|
||||||
|
args=(self._run, self._proc, self._proc.stdout,
|
||||||
|
self._buffer, self._rms, self._max_bytes),
|
||||||
|
)
|
||||||
self._thread.start()
|
self._thread.start()
|
||||||
|
|
||||||
def _pump(self):
|
def _pump(self, run, proc, stdout, buffer, rms, max_bytes):
|
||||||
proc = self._proc
|
|
||||||
stdout = proc.stdout
|
|
||||||
try:
|
try:
|
||||||
while True:
|
while True:
|
||||||
chunk = stdout.read(CHUNK_BYTES)
|
chunk = _read_exact(stdout, CHUNK_BYTES)
|
||||||
if not chunk:
|
if not chunk:
|
||||||
break
|
break
|
||||||
if self._paused:
|
if self._paused:
|
||||||
@@ -157,33 +181,58 @@ class Recorder(QObject):
|
|||||||
# nobody empties fills up, and the capture program blocks on
|
# nobody empties fills up, and the capture program blocks on
|
||||||
# a full one instead of waiting quietly for the resume.
|
# a full one instead of waiting quietly for the resume.
|
||||||
continue
|
continue
|
||||||
peak, rms = chunk_levels(chunk)
|
peak, chunk_rms = chunk_levels(chunk)
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer.extend(chunk)
|
buffer.extend(chunk)
|
||||||
self._rms.append(rms)
|
rms.append(chunk_rms)
|
||||||
too_long = len(self._buffer) >= self._max_bytes
|
too_long = len(buffer) >= max_bytes
|
||||||
|
if self._run is not run:
|
||||||
|
# This recording was given up on; whatever happens now
|
||||||
|
# belongs to the run that replaced it, not to this one.
|
||||||
|
return
|
||||||
self.level.emit(peak)
|
self.level.emit(peak)
|
||||||
if too_long:
|
if too_long:
|
||||||
self._terminate()
|
self._terminate()
|
||||||
break
|
break
|
||||||
except (OSError, ValueError):
|
except (OSError, ValueError):
|
||||||
pass
|
pass
|
||||||
|
if self._run is not run:
|
||||||
|
return
|
||||||
|
if self._stopping or self._cancelled:
|
||||||
|
return
|
||||||
|
with self._lock:
|
||||||
|
captured = bool(buffer)
|
||||||
|
if captured:
|
||||||
|
# Sound had already arrived and nobody asked it to end: the device
|
||||||
|
# went away, or the recorder fell over mid-dictation. That has to
|
||||||
|
# be said while there is still something worth keeping.
|
||||||
|
self.died.emit()
|
||||||
|
return
|
||||||
# Nobody asked it to end and it captured nothing: the recorder is not
|
# Nobody asked it to end and it captured nothing: the recorder is not
|
||||||
# installed properly, or the device was refused. Said out loud here,
|
# installed properly, or the device was refused. Said out loud here,
|
||||||
# because stop() would otherwise report it as a recording that was too
|
# because stop() would otherwise report it as a recording that was too
|
||||||
# short, which sends the user looking in the wrong place.
|
# short, which sends the user looking in the wrong place.
|
||||||
with self._lock:
|
detail = self._error_tail()
|
||||||
captured = bool(self._buffer)
|
# poll() first, because returncode stays None until somebody reaps the
|
||||||
if self._stopping or self._cancelled or captured:
|
# process, and "exit code None" answers nothing.
|
||||||
return
|
code = proc.poll()
|
||||||
try:
|
if not detail and code is not None:
|
||||||
detail = proc.stderr.read().decode("utf-8", "replace").strip()
|
detail = f"exit code {code}"
|
||||||
except (AttributeError, OSError):
|
if detail:
|
||||||
detail = ""
|
self.failed.emit(t(
|
||||||
self.failed.emit(t(
|
"Audio recorder stopped before receiving sound: {error}",
|
||||||
"Audio recorder stopped before receiving sound: {error}",
|
error=detail,
|
||||||
error=detail or f"exit code {proc.returncode}",
|
))
|
||||||
))
|
else:
|
||||||
|
self.failed.emit(t("Audio recorder stopped before receiving sound"))
|
||||||
|
|
||||||
|
def _error_tail(self):
|
||||||
|
log = self._log
|
||||||
|
return _last_log_line(log) if log is not None else ""
|
||||||
|
|
||||||
|
def _drop_log(self):
|
||||||
|
log, self._log = self._log, None
|
||||||
|
_close_log(log)
|
||||||
|
|
||||||
def _terminate(self):
|
def _terminate(self):
|
||||||
self._stopping = True
|
self._stopping = True
|
||||||
@@ -195,7 +244,10 @@ class Recorder(QObject):
|
|||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except OSError:
|
# Reaped even after a kill, or the child stays a zombie
|
||||||
|
# holding its slot in the process table.
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def cancel(self):
|
def cancel(self):
|
||||||
@@ -205,6 +257,8 @@ class Recorder(QObject):
|
|||||||
self._thread.join(timeout=2)
|
self._thread.join(timeout=2)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._run = None
|
||||||
|
self._drop_log()
|
||||||
with self._lock:
|
with self._lock:
|
||||||
self._buffer = bytearray()
|
self._buffer = bytearray()
|
||||||
|
|
||||||
@@ -217,7 +271,11 @@ class Recorder(QObject):
|
|||||||
self._thread.join(timeout=2)
|
self._thread.join(timeout=2)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._proc = None
|
self._proc = None
|
||||||
|
self._run = None
|
||||||
|
self._drop_log()
|
||||||
|
|
||||||
|
# The same buffer object the pump was handed, harvested under the same
|
||||||
|
# lock it appends with.
|
||||||
with self._lock:
|
with self._lock:
|
||||||
pcm = bytes(self._buffer)
|
pcm = bytes(self._buffer)
|
||||||
rms = list(self._rms)
|
rms = list(self._rms)
|
||||||
@@ -231,7 +289,13 @@ class Recorder(QObject):
|
|||||||
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
|
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
|
||||||
return
|
return
|
||||||
|
|
||||||
path = write_wav(pcm)
|
try:
|
||||||
|
path = write_wav(pcm)
|
||||||
|
except (OSError, wave.Error) as exc:
|
||||||
|
# A full disk or an unwritable temp directory costs this recording
|
||||||
|
# either way; a message beats a traceback in the journal.
|
||||||
|
self.failed.emit(t("Could not write the recording: {error}", error=exc))
|
||||||
|
return
|
||||||
self.stopped.emit(path, frames / RATE, rms)
|
self.stopped.emit(path, frames / RATE, rms)
|
||||||
|
|
||||||
|
|
||||||
@@ -284,6 +348,7 @@ class MeetingRecorder(QObject):
|
|||||||
def __init__(self, parent=None):
|
def __init__(self, parent=None):
|
||||||
super().__init__(parent)
|
super().__init__(parent)
|
||||||
self._procs = []
|
self._procs = []
|
||||||
|
self._interrupted = set()
|
||||||
self._thread = None
|
self._thread = None
|
||||||
self._wav = None
|
self._wav = None
|
||||||
self._logs = []
|
self._logs = []
|
||||||
@@ -336,6 +401,7 @@ class MeetingRecorder(QObject):
|
|||||||
# nobody drains would eventually block it, so it writes to a file.
|
# nobody drains would eventually block it, so it writes to a file.
|
||||||
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
||||||
self._procs = []
|
self._procs = []
|
||||||
|
self._interrupted = set()
|
||||||
for command, log in zip(commands, self._logs):
|
for command, log in zip(commands, self._logs):
|
||||||
self._procs.append(subprocess.Popen(
|
self._procs.append(subprocess.Popen(
|
||||||
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
command, stdout=subprocess.PIPE, stderr=log, bufsize=0,
|
||||||
@@ -440,14 +506,20 @@ class MeetingRecorder(QObject):
|
|||||||
try:
|
try:
|
||||||
_interrupt(proc)
|
_interrupt(proc)
|
||||||
except OSError:
|
except OSError:
|
||||||
pass
|
continue
|
||||||
|
# ffmpeg reports being interrupted as a failure; stop() needs to
|
||||||
|
# know which exits were our own doing and which were real deaths.
|
||||||
|
self._interrupted.add(proc)
|
||||||
for proc in running:
|
for proc in running:
|
||||||
try:
|
try:
|
||||||
proc.wait(timeout=2)
|
proc.wait(timeout=2)
|
||||||
except (subprocess.TimeoutExpired, OSError):
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
try:
|
try:
|
||||||
proc.kill()
|
proc.kill()
|
||||||
except OSError:
|
# Reaped even after a kill, or the child stays a zombie
|
||||||
|
# holding its slot in the process table.
|
||||||
|
proc.wait(timeout=1)
|
||||||
|
except (subprocess.TimeoutExpired, OSError):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def _close_file(self):
|
def _close_file(self):
|
||||||
@@ -460,24 +532,23 @@ class MeetingRecorder(QObject):
|
|||||||
pass
|
pass
|
||||||
|
|
||||||
def _error_tail(self):
|
def _error_tail(self):
|
||||||
tails = []
|
tails = [_last_log_line(log) for log in self._logs]
|
||||||
for log in self._logs:
|
return " | ".join(tail for tail in tails if tail)
|
||||||
try:
|
|
||||||
log.seek(0)
|
|
||||||
text = log.read().decode("utf-8", "replace").strip()
|
|
||||||
except OSError:
|
|
||||||
continue
|
|
||||||
lines = [line for line in text.splitlines() if line.strip()]
|
|
||||||
if lines:
|
|
||||||
tails.append(lines[-1])
|
|
||||||
return " | ".join(tails)
|
|
||||||
|
|
||||||
def _finish_process(self):
|
def _finish_process(self):
|
||||||
self._terminate()
|
self._terminate()
|
||||||
if self._thread:
|
if self._thread:
|
||||||
self._thread.join(timeout=3)
|
self._thread.join(timeout=3)
|
||||||
self._thread = None
|
self._thread = None
|
||||||
codes = [proc.poll() for proc in self._procs]
|
codes = []
|
||||||
|
for proc in self._procs:
|
||||||
|
code = proc.poll()
|
||||||
|
# A nonzero exit from a process we interrupted ourselves is ffmpeg
|
||||||
|
# complaining about our own stop; one that had already died on its
|
||||||
|
# own keeps its code, because that one is the story.
|
||||||
|
if code and proc in self._interrupted:
|
||||||
|
code = 0
|
||||||
|
codes.append(code)
|
||||||
code = next((value for value in codes if value), 0)
|
code = next((value for value in codes if value), 0)
|
||||||
self._procs = []
|
self._procs = []
|
||||||
self._close_file()
|
self._close_file()
|
||||||
@@ -534,13 +605,30 @@ class MeetingRecorder(QObject):
|
|||||||
|
|
||||||
def _drop_log(self):
|
def _drop_log(self):
|
||||||
for log in self._logs:
|
for log in self._logs:
|
||||||
try:
|
_close_log(log)
|
||||||
log.close()
|
|
||||||
except OSError:
|
|
||||||
pass
|
|
||||||
self._logs = []
|
self._logs = []
|
||||||
|
|
||||||
|
|
||||||
|
def _last_log_line(log):
|
||||||
|
"""The last thing a recorder said before it ended, or ''."""
|
||||||
|
try:
|
||||||
|
log.seek(0)
|
||||||
|
text = log.read().decode("utf-8", "replace").strip()
|
||||||
|
except (OSError, ValueError):
|
||||||
|
return ""
|
||||||
|
lines = [line for line in text.splitlines() if line.strip()]
|
||||||
|
return lines[-1] if lines else ""
|
||||||
|
|
||||||
|
|
||||||
|
def _close_log(log):
|
||||||
|
if log is None:
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
log.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
def chunk_levels(chunk):
|
def chunk_levels(chunk):
|
||||||
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
|
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
|
||||||
samples = array.array("h")
|
samples = array.array("h")
|
||||||
@@ -549,7 +637,9 @@ def chunk_levels(chunk):
|
|||||||
return 0.0, 0.0
|
return 0.0, 0.0
|
||||||
samples.frombytes(chunk[:usable])
|
samples.frombytes(chunk[:usable])
|
||||||
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
|
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
|
||||||
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0
|
power = (sumprod(samples, samples) if sumprod is not None
|
||||||
|
else sum(s * s for s in samples))
|
||||||
|
rms = math.sqrt(power / len(samples)) / 32768.0
|
||||||
return min(1.0, peak), min(1.0, rms)
|
return min(1.0, peak), min(1.0, rms)
|
||||||
|
|
||||||
|
|
||||||
@@ -638,6 +728,13 @@ MERGE_FILTER = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# Whether pw-record takes --raw, asked of the binary once per process: the
|
||||||
|
# probe costs a subprocess, and the answer cannot change under a running
|
||||||
|
# application. Kept here rather than inside _pw_record_raw_option so the probe
|
||||||
|
# itself stays testable against different binaries.
|
||||||
|
_PW_RAW = None
|
||||||
|
|
||||||
|
|
||||||
def _pulse_record(target):
|
def _pulse_record(target):
|
||||||
"""parec, or pw-record where PulseAudio's tools were left out.
|
"""parec, or pw-record where PulseAudio's tools were left out.
|
||||||
|
|
||||||
@@ -645,6 +742,7 @@ def _pulse_record(target):
|
|||||||
service, and its source names are the same ones shown by list_sources().
|
service, and its source names are the same ones shown by list_sources().
|
||||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||||
"""
|
"""
|
||||||
|
global _PW_RAW
|
||||||
if shutil.which("parec"):
|
if shutil.which("parec"):
|
||||||
cmd = [
|
cmd = [
|
||||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||||
@@ -660,8 +758,10 @@ def _pulse_record(target):
|
|||||||
cmd.append(f"--device={target}")
|
cmd.append(f"--device={target}")
|
||||||
return cmd
|
return cmd
|
||||||
if shutil.which("pw-record"):
|
if shutil.which("pw-record"):
|
||||||
|
if _PW_RAW is None:
|
||||||
|
_PW_RAW = _pw_record_raw_option()
|
||||||
cmd = [
|
cmd = [
|
||||||
"pw-record", *_pw_record_raw_option(), f"--rate={RATE}",
|
"pw-record", *_PW_RAW, f"--rate={RATE}",
|
||||||
f"--channels={CHANNELS}", "--format=s16",
|
f"--channels={CHANNELS}", "--format=s16",
|
||||||
]
|
]
|
||||||
if target:
|
if target:
|
||||||
@@ -682,8 +782,11 @@ def _pw_record_raw_option():
|
|||||||
that line, so ask the installed binary which form it understands.
|
that line, so ask the installed binary which form it understands.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
|
# utf-8 spelled out: subprocess otherwise decodes with the locale's
|
||||||
|
# codec, and help text through a codec it was not written in raises.
|
||||||
result = subprocess.run(
|
result = subprocess.run(
|
||||||
["pw-record", "--help"], capture_output=True, text=True, timeout=2
|
["pw-record", "--help"], capture_output=True, text=True,
|
||||||
|
encoding="utf-8", errors="replace", timeout=2,
|
||||||
)
|
)
|
||||||
help_text = (result.stdout or "") + (result.stderr or "")
|
help_text = (result.stdout or "") + (result.stderr or "")
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
@@ -708,9 +811,12 @@ def _pactl_sources():
|
|||||||
if not shutil.which("pactl"):
|
if not shutil.which("pactl"):
|
||||||
return []
|
return []
|
||||||
try:
|
try:
|
||||||
|
# utf-8 spelled out: device descriptions carry whatever alphabet the
|
||||||
|
# machine speaks, and the locale's codec is not always able to say so.
|
||||||
out = subprocess.run(
|
out = subprocess.run(
|
||||||
["pactl", "-f", "json", "list", "sources"],
|
["pactl", "-f", "json", "list", "sources"],
|
||||||
capture_output=True, text=True, timeout=5, check=True,
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||||
|
timeout=5, check=True,
|
||||||
).stdout
|
).stdout
|
||||||
return json.loads(out)
|
return json.loads(out)
|
||||||
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
||||||
@@ -739,7 +845,8 @@ def _pulse_default_output():
|
|||||||
try:
|
try:
|
||||||
sink = subprocess.run(
|
sink = subprocess.run(
|
||||||
["pactl", "get-default-sink"],
|
["pactl", "get-default-sink"],
|
||||||
capture_output=True, text=True, timeout=5, check=True,
|
capture_output=True, text=True, encoding="utf-8", errors="replace",
|
||||||
|
timeout=5, check=True,
|
||||||
).stdout.strip()
|
).stdout.strip()
|
||||||
except (subprocess.SubprocessError, OSError):
|
except (subprocess.SubprocessError, OSError):
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -13,8 +13,16 @@ platform as an argument so that a test can stand on the other one.
|
|||||||
|
|
||||||
import os
|
import os
|
||||||
import pathlib
|
import pathlib
|
||||||
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
|
|
||||||
|
# The one other platform constant every subprocess site needs, kept in this
|
||||||
|
# leaf so no caller has to pull the audio stack in for it: console programs
|
||||||
|
# started from a windowless process would otherwise each open a console window
|
||||||
|
# of their own on Windows.
|
||||||
|
NO_WINDOW = (getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||||
|
if sys.platform == "win32" else 0)
|
||||||
|
|
||||||
|
|
||||||
def _env(var, default):
|
def _env(var, default):
|
||||||
"""The directory a variable names, or the one it stands in for."""
|
"""The directory a variable names, or the one it stands in for."""
|
||||||
|
|||||||
+203
-14
@@ -81,6 +81,14 @@ class ChunkLevels(unittest.TestCase):
|
|||||||
self.assertEqual(peak, 1.0)
|
self.assertEqual(peak, 1.0)
|
||||||
self.assertEqual(rms, 1.0)
|
self.assertEqual(rms, 1.0)
|
||||||
|
|
||||||
|
def test_the_fast_and_plain_rms_paths_agree(self):
|
||||||
|
"""sumprod is a speedup, not a different sum: on a 3.11 machine the
|
||||||
|
loop must land on the same integers."""
|
||||||
|
chunk = tone(0.1)
|
||||||
|
with mock.patch.object(audio, "sumprod", None):
|
||||||
|
plain = audio.chunk_levels(chunk)
|
||||||
|
self.assertEqual(audio.chunk_levels(chunk), plain)
|
||||||
|
|
||||||
|
|
||||||
class StereoLevels(unittest.TestCase):
|
class StereoLevels(unittest.TestCase):
|
||||||
def test_the_channels_are_read_apart(self):
|
def test_the_channels_are_read_apart(self):
|
||||||
@@ -280,6 +288,48 @@ class _StalledStream:
|
|||||||
self._released.set()
|
self._released.set()
|
||||||
|
|
||||||
|
|
||||||
|
class _DribblingStream:
|
||||||
|
"""A pipe that never fills a whole chunk in one read, the way an unbuffered
|
||||||
|
pipe hands data over under load."""
|
||||||
|
|
||||||
|
def __init__(self, data, piece):
|
||||||
|
self._data = io.BytesIO(data)
|
||||||
|
self._piece = piece
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
return self._data.read(min(size, self._piece))
|
||||||
|
|
||||||
|
|
||||||
|
class _RunSwappingStream:
|
||||||
|
"""A pipe whose recorder moves on mid-read, the way a new recording starts
|
||||||
|
while a stale pump is still draining the old one."""
|
||||||
|
|
||||||
|
def __init__(self, data, recorder, swap_at, new_proc):
|
||||||
|
self._data = io.BytesIO(data)
|
||||||
|
self._recorder = recorder
|
||||||
|
self._swap_at = swap_at
|
||||||
|
self._new_proc = new_proc
|
||||||
|
self.reads = 0
|
||||||
|
|
||||||
|
def read(self, size):
|
||||||
|
if self.reads == self._swap_at:
|
||||||
|
self._recorder._run = object()
|
||||||
|
self._recorder._proc = self._new_proc
|
||||||
|
self.reads += 1
|
||||||
|
return self._data.read(size)
|
||||||
|
|
||||||
|
|
||||||
|
class _SignalCrashingProcess(FakeProcess):
|
||||||
|
"""An ffmpeg that calls being interrupted a failure, the way ffmpeg does."""
|
||||||
|
|
||||||
|
def __init__(self, data, code=255):
|
||||||
|
super().__init__(data)
|
||||||
|
self._code = code
|
||||||
|
|
||||||
|
def poll(self):
|
||||||
|
return None if self._alive else self._code
|
||||||
|
|
||||||
|
|
||||||
class _HeldStream:
|
class _HeldStream:
|
||||||
"""A capture that is paused and taken up again partway through, the way a
|
"""A capture that is paused and taken up again partway through, the way a
|
||||||
key press lands in the middle of a recording rather than between two."""
|
key press lands in the middle of a recording rather than between two."""
|
||||||
@@ -307,7 +357,10 @@ class RecordingCommand(OnLinux, DikteTest):
|
|||||||
super().setUp()
|
super().setUp()
|
||||||
# Whether pw-record takes --raw is read off the installed binary, and
|
# Whether pw-record takes --raw is read off the installed binary, and
|
||||||
# what is being tested here is the command rather than the machine the
|
# what is being tested here is the command rather than the machine the
|
||||||
# test is running on. PwRecordRawOption covers the reading itself.
|
# test is running on. PwRecordRawOption covers the reading itself. The
|
||||||
|
# answer is remembered between calls, so it cannot be remembered
|
||||||
|
# between tests.
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
self.enterContext(mock.patch.object(
|
self.enterContext(mock.patch.object(
|
||||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||||
|
|
||||||
@@ -392,11 +445,30 @@ class PwRecordRawOption(DikteTest):
|
|||||||
self.assertEqual(["--raw"], self.option(return_value=FakeCompleted()))
|
self.assertEqual(["--raw"], self.option(return_value=FakeCompleted()))
|
||||||
|
|
||||||
|
|
||||||
|
class PwRawMemo(OnLinux, DikteTest):
|
||||||
|
"""The --raw probe runs once per process, not once per key press."""
|
||||||
|
|
||||||
|
def setUp(self):
|
||||||
|
super().setUp()
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
|
|
||||||
|
def test_the_probe_is_asked_once_and_remembered(self):
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(audio, "_pw_record_raw_option",
|
||||||
|
return_value=["--raw"]) as probe:
|
||||||
|
first = audio.recording_command()
|
||||||
|
second = audio.recording_command()
|
||||||
|
probe.assert_called_once_with()
|
||||||
|
self.assertIn("--raw", first)
|
||||||
|
self.assertEqual(first, second)
|
||||||
|
|
||||||
|
|
||||||
class RecorderChain(OnLinux, DikteTest):
|
class RecorderChain(OnLinux, DikteTest):
|
||||||
"""Start to WAV, with pw-record faked out."""
|
"""Start to WAV, with pw-record faked out."""
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
self.enterContext(mock.patch.object(
|
self.enterContext(mock.patch.object(
|
||||||
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
audio, "_pw_record_raw_option", return_value=["--raw"]))
|
||||||
|
|
||||||
@@ -476,42 +548,121 @@ class RecorderChain(OnLinux, DikteTest):
|
|||||||
self.assertEqual(len(failures), 1)
|
self.assertEqual(len(failures), 1)
|
||||||
self.assertIn("pulseaudio-utils", failures[0])
|
self.assertIn("pulseaudio-utils", failures[0])
|
||||||
|
|
||||||
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False):
|
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False,
|
||||||
|
alive=False):
|
||||||
"""Run the pump in this thread, where a queued signal would need an
|
"""Run the pump in this thread, where a queued signal would need an
|
||||||
event loop nobody is running here."""
|
event loop nobody is running here."""
|
||||||
recorder = audio.Recorder()
|
recorder = audio.Recorder()
|
||||||
failures = []
|
failures = []
|
||||||
|
deaths = []
|
||||||
recorder.failed.connect(failures.append)
|
recorder.failed.connect(failures.append)
|
||||||
|
recorder.died.connect(lambda: deaths.append(True))
|
||||||
proc = FakeProcess(data)
|
proc = FakeProcess(data)
|
||||||
proc.stderr = io.BytesIO(stderr)
|
proc._alive = alive
|
||||||
proc._alive = False
|
|
||||||
recorder._proc = proc
|
recorder._proc = proc
|
||||||
recorder._max_bytes = 10 ** 9
|
recorder._log = io.BytesIO(stderr)
|
||||||
recorder._stopping = stopping
|
recorder._stopping = stopping
|
||||||
recorder._cancelled = cancelled
|
recorder._cancelled = cancelled
|
||||||
recorder._pump()
|
recorder._run = run = object()
|
||||||
return failures
|
recorder._pump(run, proc, proc.stdout, recorder._buffer,
|
||||||
|
recorder._rms, 10 ** 9)
|
||||||
|
return failures, deaths
|
||||||
|
|
||||||
def test_a_recorder_that_died_on_its_own_says_so(self):
|
def test_a_recorder_that_died_on_its_own_says_so(self):
|
||||||
"""parec refused the device, or the sound server went away."""
|
"""parec refused the device, or the sound server went away."""
|
||||||
failures = self.pump(stderr=b"connection refused\n")
|
failures, _ = self.pump(stderr=b"connection refused\n")
|
||||||
self.assertEqual(len(failures), 1)
|
self.assertEqual(len(failures), 1)
|
||||||
self.assertIn("connection refused", failures[0])
|
self.assertIn("connection refused", failures[0])
|
||||||
|
|
||||||
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
|
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
|
||||||
failures = self.pump()
|
failures, _ = self.pump()
|
||||||
self.assertIn("exit code", failures[0])
|
self.assertIn("exit code", failures[0])
|
||||||
|
|
||||||
|
def test_a_death_that_left_no_exit_code_is_not_named_none(self):
|
||||||
|
"""A process nobody managed to reap has no code to show, and "exit
|
||||||
|
code None" would only puzzle the person reading it."""
|
||||||
|
failures, _ = self.pump(alive=True)
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertNotIn("None", failures[0])
|
||||||
|
|
||||||
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
|
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
|
||||||
"""Otherwise a stray keypress produces two errors, and the first one
|
"""Otherwise a stray keypress produces two errors, and the first one
|
||||||
sends the user looking for a broken sound server."""
|
sends the user looking for a broken sound server."""
|
||||||
self.assertEqual(self.pump(stopping=True), [])
|
self.assertEqual(self.pump(stopping=True), ([], []))
|
||||||
|
|
||||||
def test_a_cancelled_recording_is_not_a_death(self):
|
def test_a_cancelled_recording_is_not_a_death(self):
|
||||||
self.assertEqual(self.pump(cancelled=True), [])
|
self.assertEqual(self.pump(cancelled=True), ([], []))
|
||||||
|
|
||||||
def test_a_recorder_that_captured_something_first_is_not_a_death(self):
|
def test_a_capture_that_ends_mid_recording_dies_rather_than_fails(self):
|
||||||
self.assertEqual(self.pump(data=silence(0.5)), [])
|
"""Sound had already arrived, so this is not a broken installation:
|
||||||
|
the app is told the recording died and can rescue what there is."""
|
||||||
|
failures, deaths = self.pump(data=silence(0.5))
|
||||||
|
self.assertEqual(failures, [])
|
||||||
|
self.assertEqual(deaths, [True])
|
||||||
|
|
||||||
|
def test_short_pipe_reads_are_gathered_into_whole_chunks(self):
|
||||||
|
"""Every RMS entry must stand for one full chunk, or the silence check
|
||||||
|
weighs a half-filled read as its own stretch of room tone."""
|
||||||
|
half = audio.CHUNK_BYTES // 2
|
||||||
|
data = pcm([1000] * (3 * half // 2)) # three half-chunk reads
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
proc = FakeProcess(b"")
|
||||||
|
proc.stdout = _DribblingStream(data, half)
|
||||||
|
proc._alive = False
|
||||||
|
recorder._proc = proc
|
||||||
|
recorder._log = io.BytesIO(b"")
|
||||||
|
recorder._run = run = object()
|
||||||
|
buffer, rms = bytearray(), []
|
||||||
|
recorder._pump(run, proc, proc.stdout, buffer, rms, 10 ** 9)
|
||||||
|
self.assertEqual(len(buffer), len(data))
|
||||||
|
self.assertEqual(len(rms), 2) # one whole chunk, then the tail
|
||||||
|
|
||||||
|
def test_a_stale_pump_cannot_touch_the_recording_that_replaced_it(self):
|
||||||
|
"""A pump that outlives its join must not meter the next run, stop its
|
||||||
|
process, push audio into its buffer, or speak on its behalf."""
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
levels, failures, deaths = [], [], []
|
||||||
|
recorder.level.connect(levels.append)
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
recorder.died.connect(lambda: deaths.append(True))
|
||||||
|
new_proc = FakeProcess(b"")
|
||||||
|
old_proc = FakeProcess(b"")
|
||||||
|
old_proc.stdout = _RunSwappingStream(tone(0.192), recorder,
|
||||||
|
swap_at=1, new_proc=new_proc)
|
||||||
|
recorder._proc = old_proc
|
||||||
|
recorder._log = io.BytesIO(b"")
|
||||||
|
old_run = object()
|
||||||
|
recorder._run = old_run
|
||||||
|
recorder._buffer = bytearray() # the next recording's buffer
|
||||||
|
old_buffer, old_rms = bytearray(), []
|
||||||
|
recorder._pump(old_run, old_proc, old_proc.stdout, old_buffer, old_rms,
|
||||||
|
2 * audio.CHUNK_BYTES)
|
||||||
|
# Metered once, then the new run took over: the over-length cutoff hit
|
||||||
|
# on the next chunk and had to stand down instead of stopping a
|
||||||
|
# process that was never its own.
|
||||||
|
self.assertEqual(len(levels), 1)
|
||||||
|
self.assertEqual(len(old_buffer), 2 * audio.CHUNK_BYTES)
|
||||||
|
self.assertEqual(new_proc.signals, [])
|
||||||
|
self.assertEqual(old_proc.signals, [])
|
||||||
|
self.assertEqual(recorder._buffer, bytearray())
|
||||||
|
self.assertEqual((failures, deaths), ([], []))
|
||||||
|
|
||||||
|
def test_a_wav_that_cannot_be_written_is_reported_not_raised(self):
|
||||||
|
recorder = audio.Recorder()
|
||||||
|
results, failures = [], []
|
||||||
|
recorder.stopped.connect(lambda *args: results.append(args))
|
||||||
|
recorder.failed.connect(failures.append)
|
||||||
|
proc = FakeProcess(tone(1.0))
|
||||||
|
with only_these_tools("pw-record"), \
|
||||||
|
mock.patch.object(subprocess, "Popen", return_value=proc), \
|
||||||
|
mock.patch.object(audio, "write_wav",
|
||||||
|
side_effect=OSError("disk full")):
|
||||||
|
recorder.start()
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(results, [])
|
||||||
|
self.assertEqual(len(failures), 1)
|
||||||
|
self.assertIn("disk full", failures[0])
|
||||||
|
|
||||||
def test_a_short_recording_reports_only_that(self):
|
def test_a_short_recording_reports_only_that(self):
|
||||||
_, results, failures, _ = self.record(silence(0.1))
|
_, results, failures, _ = self.record(silence(0.1))
|
||||||
@@ -722,6 +873,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest):
|
|||||||
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
_, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5))
|
||||||
self.assertTrue(all(process.signals for process in processes))
|
self.assertTrue(all(process.signals for process in processes))
|
||||||
|
|
||||||
|
def test_a_stop_we_asked_for_is_not_reported_as_an_ffmpeg_failure(self):
|
||||||
|
"""ffmpeg exits 255 when interrupted, and the interruption was our own
|
||||||
|
stop: a meeting ended at once must say "too short", not "ffmpeg → 255"."""
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
failed = []
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
processes = [_SignalCrashingProcess(tone(0.1)),
|
||||||
|
_SignalCrashingProcess(tone(0.1))]
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(len(failed), 1)
|
||||||
|
self.assertIn("0.3", failed[0])
|
||||||
|
self.assertNotIn("255", failed[0])
|
||||||
|
|
||||||
|
def test_an_ffmpeg_that_died_on_its_own_keeps_its_exit_code(self):
|
||||||
|
"""A process nobody interrupted has a story to tell, and its code is
|
||||||
|
the only lead the user gets."""
|
||||||
|
path = str(self.path("meeting.wav"))
|
||||||
|
recorder = audio.MeetingRecorder()
|
||||||
|
failed = []
|
||||||
|
recorder.failed.connect(failed.append)
|
||||||
|
dead = _SignalCrashingProcess(tone(0.1))
|
||||||
|
dead._alive = False # it fell over before stop() reached it
|
||||||
|
processes = [dead, FakeProcess(tone(0.1))]
|
||||||
|
with only_these_tools("ffmpeg"), self.devices(), \
|
||||||
|
mock.patch.object(subprocess, "Popen", side_effect=processes):
|
||||||
|
recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch")
|
||||||
|
recorder._thread.join(timeout=5)
|
||||||
|
recorder.stop()
|
||||||
|
self.assertEqual(len(failed), 1)
|
||||||
|
self.assertIn("255", failed[0])
|
||||||
|
|
||||||
def test_a_legacy_numeric_target_fails_before_recording(self):
|
def test_a_legacy_numeric_target_fails_before_recording(self):
|
||||||
recorder = audio.MeetingRecorder()
|
recorder = audio.MeetingRecorder()
|
||||||
failed = []
|
failed = []
|
||||||
@@ -953,9 +1140,11 @@ class WindowsDevices(OnWindows, DikteTest):
|
|||||||
def setUp(self):
|
def setUp(self):
|
||||||
super().setUp()
|
super().setUp()
|
||||||
# The listing is remembered between calls, so that a dictation does not
|
# The listing is remembered between calls, so that a dictation does not
|
||||||
# run ffmpeg of its own. It cannot be remembered between tests.
|
# run ffmpeg of its own. It cannot be remembered between tests, and
|
||||||
|
# neither can the pw-record probe's answer.
|
||||||
audio._DSHOW_SEEN.clear()
|
audio._DSHOW_SEEN.clear()
|
||||||
self.addCleanup(audio._DSHOW_SEEN.clear)
|
self.addCleanup(audio._DSHOW_SEEN.clear)
|
||||||
|
self.enterContext(mock.patch.object(audio, "_PW_RAW", None))
|
||||||
|
|
||||||
@contextlib.contextmanager
|
@contextlib.contextmanager
|
||||||
def listing(self, stderr=None, tools=("ffmpeg",)):
|
def listing(self, stderr=None, tools=("ffmpeg",)):
|
||||||
|
|||||||
Reference in New Issue
Block a user