Tell a recorder that died from one that was asked to stop

The pump now says when the capture ended with nothing captured, which is worth
saying: parec refusing the device looks like silence otherwise. But stop() ends
it the same way, so a recording shorter than 0.3 s raised that alarm first and
"Recording too short" second, sending the user after a sound server that is
fine. It follows the flag MeetingRecorder already carries for this.

test_desktop_compat.py moves into the files for the modules it covers, so a
test is where the next person looking at that module will find it.
This commit is contained in:
yusufipk
2026-08-01 20:34:15 +07:00
parent 1544cca15c
commit 45a064e545
2 changed files with 115 additions and 19 deletions
+11 -1
View File
@@ -44,6 +44,7 @@ class Recorder(QObject):
self._buffer = bytearray() self._buffer = bytearray()
self._rms = [] self._rms = []
self._cancelled = False self._cancelled = False
self._stopping = False
self._lock = threading.Lock() self._lock = threading.Lock()
@property @property
@@ -71,6 +72,7 @@ class Recorder(QObject):
self._buffer = bytearray() self._buffer = bytearray()
self._rms = [] self._rms = []
self._cancelled = False self._cancelled = False
self._stopping = 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) self._thread = threading.Thread(target=self._pump, daemon=True)
self._thread.start() self._thread.start()
@@ -94,7 +96,14 @@ class Recorder(QObject):
break break
except (OSError, ValueError): except (OSError, ValueError):
pass pass
if not self._cancelled and not self._buffer and proc.poll() is not None: # Nobody asked it to end and it captured nothing: the recorder is not
# installed properly, or the device was refused. Said out loud here,
# because stop() would otherwise report it as a recording that was too
# short, which sends the user looking in the wrong place.
with self._lock:
captured = bool(self._buffer)
if self._stopping or self._cancelled or captured:
return
try: try:
detail = proc.stderr.read().decode("utf-8", "replace").strip() detail = proc.stderr.read().decode("utf-8", "replace").strip()
except (AttributeError, OSError): except (AttributeError, OSError):
@@ -105,6 +114,7 @@ class Recorder(QObject):
)) ))
def _terminate(self): def _terminate(self):
self._stopping = True
proc = self._proc proc = self._proc
if proc and proc.poll() is None: if proc and proc.poll() is None:
try: try:
+96 -10
View File
@@ -7,6 +7,7 @@ speakers, and neither list may go missing when pactl is absent.
import array import array
import contextlib import contextlib
import io
import json import json
import os import os
import subprocess import subprocess
@@ -194,10 +195,10 @@ class FakeProcess:
"""A pw-record that hands over a fixed buffer and then ends.""" """A pw-record that hands over a fixed buffer and then ends."""
def __init__(self, data): def __init__(self, data):
import io
self.stdout = io.BytesIO(data) self.stdout = io.BytesIO(data)
self.stderr = io.BytesIO(b"") self.stderr = io.BytesIO(b"")
self.signals = [] self.signals = []
self.returncode = 0
self._alive = True self._alive = True
def poll(self): def poll(self):
@@ -215,6 +216,48 @@ class FakeProcess:
self._alive = False self._alive = False
@linux_only
class RecordingCommand(DikteTest):
"""Which program captures the microphone, and how it is asked to."""
def test_parec_is_preferred(self):
"""It speaks to PulseAudio and to PipeWire's compatibility service, so
it is the one that works on both desktops."""
with only_these_tools("parec", "pw-record"):
self.assertEqual(audio.recording_command()[0], "parec")
def test_pw_record_is_the_fallback(self):
with only_these_tools("pw-record"):
self.assertEqual(audio.recording_command()[0], "pw-record")
def test_neither_is_installed(self):
with only_these_tools():
self.assertEqual(audio.recording_command(), [])
def test_both_capture_the_format_the_rest_of_the_code_expects(self):
for tool in ("parec", "pw-record"):
with self.subTest(tool=tool), only_these_tools(tool):
cmd = audio.recording_command()
joined = " ".join(cmd)
self.assertIn(str(audio.RATE), joined)
self.assertIn(str(audio.CHANNELS), joined)
self.assertIn("s16", joined)
def test_a_chosen_microphone_reaches_either_one(self):
with only_these_tools("parec"):
self.assertIn("--device=alsa_input.usb", audio.recording_command(
"alsa_input.usb"))
with only_these_tools("pw-record"):
self.assertIn("--target=alsa_input.usb", audio.recording_command(
"alsa_input.usb"))
def test_no_microphone_named_means_no_device_flag(self):
for tool, flag in (("parec", "--device="), ("pw-record", "--target=")):
with self.subTest(tool=tool), only_these_tools(tool):
self.assertFalse([arg for arg in audio.recording_command()
if arg.startswith(flag)])
@linux_only @linux_only
class RecorderChain(DikteTest): class RecorderChain(DikteTest):
"""Start to WAV, with pw-record faked out.""" """Start to WAV, with pw-record faked out."""
@@ -233,15 +276,6 @@ class RecorderChain(DikteTest):
recorder.stop() recorder.stop()
return recorder, results, failures, popen return recorder, results, failures, popen
def test_pw_record_is_not_installed(self):
recorder = audio.Recorder()
failures = []
recorder.failed.connect(failures.append)
with only_these_tools():
recorder.start()
self.assertEqual(len(failures), 1)
self.assertIn("pipewire", failures[0])
def test_the_capture_format_is_what_the_rest_of_the_code_expects(self): def test_the_capture_format_is_what_the_rest_of_the_code_expects(self):
_, _, _, popen = self.record(silence(1.0)) _, _, _, popen = self.record(silence(1.0))
cmd = popen.call_args.args[0] cmd = popen.call_args.args[0]
@@ -295,6 +329,58 @@ class RecorderChain(DikteTest):
self.addCleanup(os.unlink, path) self.addCleanup(os.unlink, path)
self.assertLessEqual(duration, 1.1) self.assertLessEqual(duration, 1.1)
def test_a_recorder_that_is_not_installed_at_all(self):
recorder = audio.Recorder()
failures = []
recorder.failed.connect(failures.append)
with only_these_tools():
recorder.start()
self.assertEqual(len(failures), 1)
self.assertIn("pulseaudio-utils", failures[0])
def pump(self, data=b"", stderr=b"", stopping=False, cancelled=False):
"""Run the pump in this thread, where a queued signal would need an
event loop nobody is running here."""
recorder = audio.Recorder()
failures = []
recorder.failed.connect(failures.append)
proc = FakeProcess(data)
proc.stderr = io.BytesIO(stderr)
proc._alive = False
recorder._proc = proc
recorder._max_bytes = 10 ** 9
recorder._stopping = stopping
recorder._cancelled = cancelled
recorder._pump()
return failures
def test_a_recorder_that_died_on_its_own_says_so(self):
"""parec refused the device, or the sound server went away."""
failures = self.pump(stderr=b"connection refused\n")
self.assertEqual(len(failures), 1)
self.assertIn("connection refused", failures[0])
def test_a_death_with_nothing_on_stderr_still_names_the_exit_code(self):
failures = self.pump()
self.assertIn("exit code", failures[0])
def test_a_recording_we_ended_ourselves_is_not_a_death(self):
"""Otherwise a stray keypress produces two errors, and the first one
sends the user looking for a broken sound server."""
self.assertEqual(self.pump(stopping=True), [])
def test_a_cancelled_recording_is_not_a_death(self):
self.assertEqual(self.pump(cancelled=True), [])
def test_a_recorder_that_captured_something_first_is_not_a_death(self):
self.assertEqual(self.pump(data=silence(0.5)), [])
def test_a_short_recording_reports_only_that(self):
_, results, failures, _ = self.record(silence(0.1))
self.assertEqual(results, [])
self.assertEqual(len(failures), 1)
self.assertIn("0.3", failures[0])
def test_a_recorder_that_could_not_start(self): def test_a_recorder_that_could_not_start(self):
recorder = audio.Recorder() recorder = audio.Recorder()
failures = [] failures = []