From 4e19d7279f23b2f793ca14587139a4bfb50a944c Mon Sep 17 00:00:00 2001 From: Hakan Ekin Date: Sun, 2 Aug 2026 20:47:44 +0300 Subject: [PATCH 1/2] Handle pw-record versions without raw support --- audio.py | 21 ++++++++++++++++++++- tests/test_audio.py | 19 +++++++++++++++++++ 2 files changed, 39 insertions(+), 1 deletion(-) diff --git a/audio.py b/audio.py index 0ef5038..284c341 100644 --- a/audio.py +++ b/audio.py @@ -197,7 +197,7 @@ def recording_command(target=""): return cmd if shutil.which("pw-record"): cmd = [ - "pw-record", "--raw", f"--rate={RATE}", + "pw-record", *_pw_record_raw_option(), f"--rate={RATE}", f"--channels={CHANNELS}", "--format=s16", ] if target: @@ -207,6 +207,25 @@ def recording_command(target=""): return [] +def _pw_record_raw_option(): + """Use --raw only on pw-record releases that provide it. + + PipeWire 1.0, including Ubuntu 24.04's build, writes raw PCM to stdout but + rejects the newer --raw option. A rejected option ends the recorder before + it receives sound, so ask the installed binary which form it understands. + """ + try: + result = subprocess.run( + ["pw-record", "--help"], capture_output=True, text=True, timeout=2 + ) + help_text = (result.stdout or "") + (result.stderr or "") + except (subprocess.SubprocessError, OSError): + return ["--raw"] # preserve the existing command when probing itself fails + if not help_text.strip(): + return ["--raw"] + return ["--raw"] if "--raw" in help_text else [] + + class MeetingRecorder(QObject): """Microphone and speaker output into one stereo file: left is you, right is everyone else. diff --git a/tests/test_audio.py b/tests/test_audio.py index 7595d1b..9f2adf7 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -230,6 +230,23 @@ class RecordingCommand(DikteTest): with only_these_tools("pw-record"): self.assertEqual(audio.recording_command()[0], "pw-record") + def test_pw_record_uses_raw_when_the_installed_version_supports_it(self): + help_result = FakeCompleted(stdout=" --raw Write raw samples\n") + with only_these_tools("pw-record"), \ + mock.patch.object(audio.subprocess, "run", return_value=help_result): + self.assertIn("--raw", audio.recording_command()) + + def test_pw_record_omits_raw_when_pipewire_1_0_rejects_it(self): + help_result = FakeCompleted(stdout=" --rate Sample rate\n") + with only_these_tools("pw-record"), \ + mock.patch.object(audio.subprocess, "run", return_value=help_result): + self.assertNotIn("--raw", audio.recording_command()) + + def test_pw_record_help_failure_keeps_the_existing_command(self): + with only_these_tools("pw-record"), \ + mock.patch.object(audio.subprocess, "run", side_effect=OSError): + self.assertIn("--raw", audio.recording_command()) + def test_neither_is_installed(self): with only_these_tools(): self.assertEqual(audio.recording_command(), []) @@ -282,6 +299,7 @@ class RecorderChain(DikteTest): recorder.failed.connect(failures.append) proc = FakeProcess(data) with only_these_tools("pw-record"), \ + mock.patch.object(audio, "_pw_record_raw_option", return_value=[]), \ mock.patch.object(subprocess, "Popen", return_value=proc) as popen: recorder.start(target=target, max_seconds=max_seconds) recorder._thread.join(timeout=5) @@ -328,6 +346,7 @@ class RecorderChain(DikteTest): recorder.stopped.connect(lambda *args: results.append(args)) proc = FakeProcess(tone(1.0)) with only_these_tools("pw-record"), \ + mock.patch.object(audio, "_pw_record_raw_option", return_value=[]), \ mock.patch.object(subprocess, "Popen", return_value=proc): recorder.start() recorder._thread.join(timeout=5) From f6754ebf43c33e9e141a80b10d231063a8d3cd72 Mon Sep 17 00:00:00 2001 From: yusufipk Date: Wed, 5 Aug 2026 18:41:23 +0300 Subject: [PATCH 2/2] Name the PipeWire the check is for, and keep it out of the tests --raw arrived in 1.4, the same release that stopped reading a bare "-" as raw on its own. The split is there rather than at 1.0: Ubuntu 24.10 and anything else on 1.2 refuses the option too, and 1.4 onwards writes a container around the stream without it. Asking the installed binary meant the command tests ran pw-record --help four times for real, on a machine whose answer decides what they see, which the module docstring promises they never do. They pin the answer in setUp, and the reading itself gets its own class, the empty help nobody covered included. --- audio.py | 9 ++++--- tests/test_audio.py | 63 +++++++++++++++++++++++++++++++-------------- 2 files changed, 50 insertions(+), 22 deletions(-) diff --git a/audio.py b/audio.py index 55f0c0c..55beb50 100644 --- a/audio.py +++ b/audio.py @@ -458,9 +458,12 @@ def _pulse_record(target): def _pw_record_raw_option(): """Use --raw only on pw-record releases that provide it. - PipeWire 1.0, including Ubuntu 24.04's build, writes raw PCM to stdout but - rejects the newer --raw option. A rejected option ends the recorder before - it receives sound, so ask the installed binary which form it understands. + PipeWire gained --raw in 1.4, and in the same release stopped treating a + filename of "-" as raw on its own: before it, the option is refused and the + recorder dies before any sound arrives; after it, leaving the option out + wraps the stream in a container the rest of this file would read as noise. + Ubuntu 24.04 and anything else still on 1.0 or 1.2 sit on the near side of + that line, so ask the installed binary which form it understands. """ try: result = subprocess.run( diff --git a/tests/test_audio.py b/tests/test_audio.py index a65b8ac..22d4f2a 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -238,6 +238,14 @@ class FakeProcess: class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" + def setUp(self): + super().setUp() + # 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 + # test is running on. PwRecordRawOption covers the reading itself. + self.enterContext(mock.patch.object( + audio, "_pw_record_raw_option", return_value=["--raw"])) + 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.""" @@ -248,23 +256,6 @@ class RecordingCommand(OnLinux, DikteTest): with only_these_tools("pw-record"): self.assertEqual(audio.recording_command()[0], "pw-record") - def test_pw_record_uses_raw_when_the_installed_version_supports_it(self): - help_result = FakeCompleted(stdout=" --raw Write raw samples\n") - with only_these_tools("pw-record"), \ - mock.patch.object(audio.subprocess, "run", return_value=help_result): - self.assertIn("--raw", audio.recording_command()) - - def test_pw_record_omits_raw_when_pipewire_1_0_rejects_it(self): - help_result = FakeCompleted(stdout=" --rate Sample rate\n") - with only_these_tools("pw-record"), \ - mock.patch.object(audio.subprocess, "run", return_value=help_result): - self.assertNotIn("--raw", audio.recording_command()) - - def test_pw_record_help_failure_keeps_the_existing_command(self): - with only_these_tools("pw-record"), \ - mock.patch.object(audio.subprocess, "run", side_effect=OSError): - self.assertIn("--raw", audio.recording_command()) - def test_neither_is_installed(self): with only_these_tools(): self.assertEqual(audio.recording_command(), []) @@ -305,9 +296,45 @@ class RecordingCommand(OnLinux, DikteTest): if arg.startswith(flag)]) +class PwRecordRawOption(DikteTest): + """Two pw-record generations want opposite commands for the same stream. + + PipeWire 1.4 added --raw and stopped treating a filename of "-" as raw on + its own, so the option is refused by everything older and needed by + everything newer. The help text is the only thing that tells them apart. + """ + + def option(self, **run): + with mock.patch.object(audio.subprocess, "run", **run): + return audio._pw_record_raw_option() + + def test_a_version_that_offers_raw_is_asked_for_it(self): + self.assertEqual(["--raw"], self.option( + return_value=FakeCompleted(stdout=" -a, --raw RAW mode\n"))) + + def test_a_version_without_it_is_not(self): + self.assertEqual([], self.option( + return_value=FakeCompleted(stdout=" --rate Sample rate\n"))) + + def test_help_that_could_not_be_read_keeps_the_option(self): + """Whatever is installed, the command that worked before this check + existed is the safer guess.""" + self.assertEqual(["--raw"], self.option(side_effect=OSError)) + self.assertEqual(["--raw"], self.option( + side_effect=subprocess.TimeoutExpired("pw-record", 2))) + + def test_help_that_said_nothing_keeps_it_too(self): + self.assertEqual(["--raw"], self.option(return_value=FakeCompleted())) + + class RecorderChain(OnLinux, DikteTest): """Start to WAV, with pw-record faked out.""" + def setUp(self): + super().setUp() + self.enterContext(mock.patch.object( + audio, "_pw_record_raw_option", return_value=["--raw"])) + def record(self, data, target="", max_seconds=300): recorder = audio.Recorder() results = [] @@ -316,7 +343,6 @@ class RecorderChain(OnLinux, DikteTest): recorder.failed.connect(failures.append) proc = FakeProcess(data) with only_these_tools("pw-record"), \ - mock.patch.object(audio, "_pw_record_raw_option", return_value=[]), \ mock.patch.object(subprocess, "Popen", return_value=proc) as popen: recorder.start(target=target, max_seconds=max_seconds) recorder._thread.join(timeout=5) @@ -363,7 +389,6 @@ class RecorderChain(OnLinux, DikteTest): recorder.stopped.connect(lambda *args: results.append(args)) proc = FakeProcess(tone(1.0)) with only_these_tools("pw-record"), \ - mock.patch.object(audio, "_pw_record_raw_option", return_value=[]), \ mock.patch.object(subprocess, "Popen", return_value=proc): recorder.start() recorder._thread.join(timeout=5)