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:
huseyin-emre-tigci
2026-08-22 23:17:22 +03:00
co-authored by Claude Fable 5
parent 6f601ab969
commit 420cda9376
3 changed files with 368 additions and 64 deletions
+150 -43
View File
@@ -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 or f"exit code {proc.returncode}", error=detail,
)) ))
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
try:
path = write_wav(pcm) 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,11 +605,28 @@ class MeetingRecorder(QObject):
def _drop_log(self): def _drop_log(self):
for log in self._logs: for log in self._logs:
_close_log(log)
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: try:
log.close() log.close()
except OSError: except OSError:
pass pass
self._logs = []
def chunk_levels(chunk): def chunk_levels(chunk):
@@ -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 ""
+8
View File
@@ -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
View File
@@ -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",)):