diff --git a/audio.py b/audio.py index ccfd8b0..55beb50 100644 --- a/audio.py +++ b/audio.py @@ -445,7 +445,7 @@ def _pulse_record(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: @@ -455,6 +455,28 @@ def _pulse_record(target): return [] +def _pw_record_raw_option(): + """Use --raw only on pw-record releases that provide it. + + 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( + ["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 [] + + def _pulse_meeting(mic_target, system_target): return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", diff --git a/tests/test_audio.py b/tests/test_audio.py index ebeef67..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.""" @@ -288,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 = []