diff --git a/audio.py b/audio.py index 292b533..46e88f3 100644 --- a/audio.py +++ b/audio.py @@ -277,6 +277,14 @@ class MeetingRecorder(QObject): def start(self, path, mic_target="", system_target="", max_seconds=14400): if self.active: return + # Before ffmpeg is looked for, because installing it would not help: a + # system with no way to capture what the speakers are playing has none + # whatever else is on the machine. + if not sound().meetings: + self.failed.emit(t("This system offers nothing that records what " + "the speakers are playing, so a meeting cannot " + "be recorded on it.")) + return if not shutil.which("ffmpeg"): self.failed.emit(t("ffmpeg not found. Install it to record a meeting.")) return @@ -837,12 +845,55 @@ def _avfoundation_default_output(): # device at all, so a meeting has nothing to record the far side from yet. +# A device entry and the line under it, in the two shapes ffmpeg has printed +# this listing in. Newer builds mark each device `(audio)` or `(video)`; older +# ones print no marker and group the devices under a heading instead. Both are +# anchored at each end, so that the error lines the command ends with, which +# quote the device name that was not found, are not read as devices. +_DSHOW_ENTRY = re.compile( + r'^(?:\[dshow @ [^\]]*\]\s*)?"([^"]+)"\s*(?:\(([^)]*)\))?\s*$') +_DSHOW_ALTERNATIVE = re.compile( + r'^(?:\[dshow @ [^\]]*\]\s*)?Alternative name\s+"([^"]+)"\s*$') +_DSHOW_HEADING = re.compile(r'DirectShow (audio|video) devices') + +# The last listing taken, so that a dictation does not pay for one of its own. +_DSHOW_SEEN = [] + + +def _parse_dshow_listing(text): + """[(id, name)] for the audio devices in one ffmpeg device listing. + + Two friendly names on one machine are routinely identical: a laptop with a + headset plugged in shows two microphones called the same thing, and + `audio=` would reach only the first of them either way. The + alternative name ffmpeg prints under each device is unique and is what the + recorder is given back, while the friendly name is what a user picks from. + """ + devices = [] + heading = "" + for line in text.splitlines(): + found = _DSHOW_HEADING.search(line) + if found: + heading = found.group(1) + continue + found = _DSHOW_ALTERNATIVE.match(line.strip()) + if found: + if devices: + devices[-1][0] = found.group(1) + continue + found = _DSHOW_ENTRY.match(line.strip()) + if found: + kind = (found.group(2) or heading).lower() + devices.append([found.group(1), found.group(1), kind]) + return [(identifier, name) for identifier, name, kind in devices + if "audio" in kind] + + def _dshow_devices(): - """[(name, name)] for every DirectShow audio capture device. + """[(id, name)] for every DirectShow audio capture device, freshly asked. The list comes out on stderr of a command that then fails, the same - documented trick AVFoundation uses above. Names are the only stable handle - dshow offers a user; they are what the recorder is given back. + documented trick AVFoundation uses above. """ if not shutil.which("ffmpeg"): return [] @@ -855,26 +906,30 @@ def _dshow_devices(): except (subprocess.SubprocessError, OSError): return [] - devices = [] - for line in result.stderr.decode("utf-8", "replace").splitlines(): - if "(audio)" not in line: - continue - match = re.search(r'"([^"]+)"\s*\([^)]*audio[^)]*\)', line) - if match: - devices.append((match.group(1), match.group(1))) + devices = _parse_dshow_listing(result.stderr.decode("utf-8", "replace")) + _DSHOW_SEEN[:] = devices return devices +def _dshow_first_device(): + """The device an unset target stands for, without a listing per dictation. + + dshow has no "default" for an empty target to mean, so it has to be turned + into a name, and asking ffmpeg for one costs a process every time the key + is pressed. The last listing is used when there is one: opening Settings or + running `dikte devices` takes a fresh one, which is what somebody who has + just plugged a microphone in does anyway. + """ + devices = _DSHOW_SEEN or _dshow_devices() + return devices[0][0] if devices else "" + + def _dshow_record(target): if not shutil.which("ffmpeg"): return [] - # dshow has no "default" device: an unset target means the first one listed. - device = target + device = target or _dshow_first_device() if not device: - inputs = _dshow_devices() - if not inputs: - return [] - device = inputs[0][0] + return [] return [ "ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error", # dshow holds half a second of audio before handing anything over; @@ -901,9 +956,13 @@ Sound = collections.namedtuple( "Sound", # How to capture one source and how to capture two at once, that one as the # list of processes it takes, the two device lists, which device a meeting - # records the far side from, and what to say when the programs for any of - # it are not installed. - "record meeting inputs outputs default_output missing", + # records the far side from, whether this system can record one at all, and + # what to say when the programs for any of it are not installed. + # + # `meetings` is the sound system's own answer, not this machine's: an empty + # output list means the tool that lists them is missing, which is a thing a + # user can go and fix, while False here is a thing they cannot. + "record meeting inputs outputs default_output meetings missing", ) PULSE = Sound( @@ -912,6 +971,7 @@ PULSE = Sound( inputs=_pulse_inputs, outputs=_pulse_outputs, default_output=_pulse_default_output, + meetings=True, missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.", ) @@ -924,6 +984,8 @@ COREAUDIO = Sound( # empty list would leave nothing to pick. outputs=_avfoundation_named_inputs, default_output=_avfoundation_default_output, + # With a loopback driver installed, which is what the Settings note is for. + meetings=True, missing="ffmpeg not found. Install it with: brew install ffmpeg", ) @@ -934,6 +996,9 @@ DSHOW = Sound( inputs=_dshow_devices, outputs=_dshow_no_outputs, default_output=_dshow_no_default_output, + # Windows offers no capture device for what the speakers are playing, and + # there is no driver to install that would add one. + meetings=False, missing="ffmpeg or a microphone was not found. Install ffmpeg with: " "winget install Gyan.FFmpeg", ) diff --git a/i18n.py b/i18n.py index 15c2133..ccbbf48 100644 --- a/i18n.py +++ b/i18n.py @@ -623,6 +623,16 @@ TR = { "macOS, hoparlörden çıkan sesi kaydedilebilir bir kaynak olarak sunmaz. " "BlackHole ya da Loopback kur, toplantının sesini oradan geçir ve " "yukarıdan onu seç.", + "This system offers nothing that records what the speakers are playing, " + "so a meeting cannot be recorded on it. Dictation and transcribing a file " + "are unaffected.": + "Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; " + "burada toplantı kaydedilemez. Dikte ve dosya deşifresi bundan " + "etkilenmez.", + "This system offers nothing that records what the speakers are playing, " + "so a meeting cannot be recorded on it.": + "Bu sistem, hoparlörden çıkan sesi kaydeden hiçbir şey sunmuyor; " + "burada toplantı kaydedilemez.", "Wear headphones if you can. Through speakers your microphone hears the " "other side as well, and although a line that lands on both channels at " "once is dropped again, the repair is never as clean as not needing it.": diff --git a/settings_ui.py b/settings_ui.py index b671931..3cb6225 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -1015,6 +1015,18 @@ class SettingsWindow(QDialog): )) mac_note.setWordWrap(True) sources_form.addRow(mac_note) + elif not audio.sound().meetings: + # Windows is the system this is written for: it offers nothing that + # captures what the speakers are playing, and there is no driver to + # install that would put an entry in the list above. Left unsaid, + # the box is simply empty and the Record button fails at the press. + nothing_note = QLabel(t( + "This system offers nothing that records what the speakers are " + "playing, so a meeting cannot be recorded on it. Dictation and " + "transcribing a file are unaffected." + )) + nothing_note.setWordWrap(True) + sources_form.addRow(nothing_note) note = QLabel(t( "Wear headphones if you can. Through speakers your microphone hears " diff --git a/tests/test_audio.py b/tests/test_audio.py index 6525b0d..027a060 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -822,6 +822,42 @@ class MacRecordingCommand(OnMacOS, DikteTest): self.assertFalse(recorder.active) +class NoFarSideToRecord(DikteTest): + """Two different answers, and the table is what tells them apart. + + A sound system that records the far side has a device this machine could + not pick out, and Settings is where to choose one. A sound system that does + not had nothing to offer there in the first place, and "pick one" would + send somebody to an empty box and an installation that cannot help. + """ + + def failure(self, meetings): + recorder = audio.MeetingRecorder() + failures = [] + recorder.failed.connect(failures.append) + with only_these_tools("ffmpeg"), \ + mock.patch.object(audio, "default_monitor", return_value=""), \ + mock.patch.object(audio, "sound", + return_value=audio.PULSE._replace( + meetings=meetings)): + recorder.start(str(self.path("meeting.wav"))) + self.assertFalse(recorder.active) + return failures[0] + + def test_a_system_that_records_the_far_side_sends_you_to_settings(self): + self.assertIn("Settings", self.failure(True)) + + def test_a_system_that_does_not_says_that_instead(self): + message = self.failure(False) + self.assertIn("nothing that records what the speakers", message) + self.assertNotIn("Settings", message) + + def test_the_three_sound_systems_each_answer_the_question(self): + self.assertTrue(audio.PULSE.meetings) + self.assertTrue(audio.COREAUDIO.meetings) + self.assertFalse(audio.DSHOW.meetings) + + class OnWindows: """A test that runs as if the machine ran Windows.""" @@ -837,51 +873,108 @@ class WindowsDevices(OnWindows, DikteTest): whatever alphabet the machine speaks, so the listing here does too. """ + MIC = "@device_cm_{33D9A762}\\wave_{B1C2}" LISTING = ( '[dshow @ 0000020c] "Integrated Camera" (video)\n' '[dshow @ 0000020c] Alternative name "@device_pnp_\\...."\n' '[dshow @ 0000020c] "Mikrofon Dizisi (Intel Smart Sound)" (audio)\n' - '[dshow @ 0000020c] Alternative name "@device_cm_{33D9A762}...."\n' + f'[dshow @ 0000020c] Alternative name "{MIC}"\n' '[dshow @ 0000020c] "Kulaklık (Soundcore Life Q30)" (audio)\n' + '[dshow @ 0000020c] Could not find audio only device with name ' + '"dummy" among source devices of type audio.\n' "dummy: Immediate exit requested\n" ).encode("utf-8") + def setUp(self): + super().setUp() + # The listing is remembered between calls, so that a dictation does not + # run ffmpeg of its own. It cannot be remembered between tests. + audio._DSHOW_SEEN.clear() + self.addCleanup(audio._DSHOW_SEEN.clear) + @contextlib.contextmanager def listing(self, stderr=None, tools=("ffmpeg",)): completed = FakeCompleted( returncode=1, stderr=self.LISTING if stderr is None else stderr) with only_these_tools(*tools), \ - mock.patch.object(subprocess, "run", return_value=completed): - yield + mock.patch.object(subprocess, "run", + return_value=completed) as run: + yield run def test_windows_records_through_dshow(self): self.assertIs(audio.sound(), audio.DSHOW) - def test_the_audio_lines_are_the_only_ones_read(self): + def test_the_audio_devices_are_the_only_ones_read(self): with self.listing(): self.assertEqual(audio.list_sources(), [ - ("Mikrofon Dizisi (Intel Smart Sound)", - "Mikrofon Dizisi (Intel Smart Sound)"), + (self.MIC, "Mikrofon Dizisi (Intel Smart Sound)"), ("Kulaklık (Soundcore Life Q30)", "Kulaklık (Soundcore Life Q30)"), ]) + def test_the_device_ffmpeg_could_not_open_is_not_one_of_them(self): + """The command ends by quoting the name it was sent to look for.""" + with self.listing(): + self.assertNotIn("dummy", [name for _, name in audio.list_sources()]) + + def test_a_listing_from_an_ffmpeg_that_marks_nothing(self): + """Older builds print a heading instead of an (audio) on every line.""" + listing = ( + '[dshow @ 0] DirectShow video devices\n' + '[dshow @ 0] "Integrated Camera"\n' + '[dshow @ 0] Alternative name "@device_pnp_\\..."\n' + '[dshow @ 0] DirectShow audio devices\n' + '[dshow @ 0] "Microphone (Realtek Audio)"\n' + '[dshow @ 0] Alternative name "@device_cm_{ABCD}"\n' + ).encode("utf-8") + with self.listing(stderr=listing): + self.assertEqual(audio.list_sources(), + [("@device_cm_{ABCD}", "Microphone (Realtek Audio)")]) + + def test_two_devices_called_the_same_thing_stay_apart(self): + """The normal state of a laptop with a headset plugged into it.""" + listing = ( + '[dshow @ 0] "Microphone" (audio)\n' + '[dshow @ 0] Alternative name "@device_cm_{ONE}"\n' + '[dshow @ 0] "Microphone" (audio)\n' + '[dshow @ 0] Alternative name "@device_cm_{TWO}"\n' + ).encode("utf-8") + with self.listing(stderr=listing): + sources = audio.list_sources() + self.assertEqual([identifier for identifier, _ in sources], + ["@device_cm_{ONE}", "@device_cm_{TWO}"]) + self.assertEqual({name for _, name in sources}, {"Microphone"}) + def test_no_ffmpeg_installed(self): with only_these_tools(): self.assertEqual(audio.list_sources(), []) self.assertEqual(audio.recording_command(), []) - def test_the_name_is_what_the_recorder_is_given_back(self): + def test_the_identifier_is_what_the_recorder_is_given_back(self): with self.listing(): - cmd = audio.recording_command("Kulaklık (Soundcore Life Q30)") + cmd = audio.recording_command(self.MIC) self.assertEqual(cmd[cmd.index("-f") + 1], "dshow") - self.assertIn("audio=Kulaklık (Soundcore Life Q30)", cmd) + self.assertIn(f"audio={self.MIC}", cmd) def test_no_microphone_named_means_the_first_one_listed(self): """dshow has no default device for an empty target to mean.""" with self.listing(): - self.assertIn("audio=Mikrofon Dizisi (Intel Smart Sound)", - audio.recording_command()) + self.assertIn(f"audio={self.MIC}", audio.recording_command()) + + def test_a_dictation_does_not_run_a_listing_of_its_own(self): + """Two hundred milliseconds of ffmpeg in front of every key press.""" + with self.listing() as run: + audio.list_sources() + audio.recording_command() + audio.recording_command() + self.assertEqual(run.call_count, 1) + + def test_opening_the_device_list_asks_again(self): + """Which is what somebody who has just plugged one in does.""" + with self.listing() as run: + audio.list_sources() + audio.list_sources() + self.assertEqual(run.call_count, 2) def test_a_machine_with_no_microphone_at_all(self): with self.listing(stderr=b'[dshow @ 0] "Integrated Camera" (video)\n'): @@ -899,6 +992,15 @@ class WindowsDevices(OnWindows, DikteTest): self.assertEqual(audio.default_monitor(), "") self.assertEqual(audio.meeting_commands("mic", "sys"), []) + def test_a_meeting_says_what_is_wrong_rather_than_where_to_look(self): + recorder = audio.MeetingRecorder() + failures = [] + recorder.failed.connect(failures.append) + with self.listing(): + recorder.start(str(self.path("meeting.wav"))) + self.assertIn("nothing that records what the speakers", failures[0]) + self.assertFalse(recorder.active) + if __name__ == "__main__": unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 0cc5969..567c97f 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -13,6 +13,7 @@ from unittest import mock from PyQt6.QtWidgets import QApplication, QMessageBox +import audio import cleanup import config as cfg import ggml @@ -449,6 +450,36 @@ class Overlay(DikteTest): self.assertFalse(widget.muted) +class MeetingSources(DikteTest): + """What the Meeting tab says about the far side, per sound system. + + The box that picks it is empty on a system that cannot record it, and an + empty box with nothing next to it reads as a list that has not loaded yet. + """ + + def notes(self, meetings): + with mock.patch.object(audio, "sound", + return_value=audio.PULSE._replace( + meetings=meetings)), \ + only_these_tools(), \ + mock.patch.object(settings_ui.SettingsWindow, "_load_models"), \ + mock.patch.object(settings_ui.SettingsWindow, + "_load_transcribe_models"): + window = settings_ui.SettingsWindow(cfg.Config()) + self.addCleanup(window.deleteLater) + self.addCleanup(window.close) + return " ".join(label.text() + for label in window.findChildren(settings_ui.QLabel)) + + def test_a_system_that_cannot_record_the_far_side_says_so(self): + self.assertIn("nothing that records what the speakers", + self.notes(meetings=False)) + + def test_a_system_that_can_says_nothing_of_the_sort(self): + self.assertNotIn("nothing that records what the speakers", + self.notes(meetings=True)) + + if __name__ == "__main__": unittest.main()