mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Give Windows devices an identifier, and ask ffmpeg for them once
Three things about the dshow backend, all of them found by reading rather than by running, so all three want checking on a real Windows machine. The device listing is parsed in both of the shapes ffmpeg has printed it in: newer builds mark every device `(audio)` or `(video)`, older ones print a heading and no marks, and only the first was read. Each pattern is anchored at both ends now, so the error lines the command ends with, which quote the device name it was told to look for, are no longer read as a device of that name. What is stored for a device is the alternative name under it rather than the friendly one. A laptop with a headset plugged in has two microphones called the same thing, and `audio=Microphone` reaches the first of them whichever one was picked; the alternative name is unique. The friendly name stays what is shown, which is what the (id, description) pair in these lists has always been for. An unset microphone meant "the first one listed", and the listing costs an ffmpeg of its own, so every press of the key paid for a process before the recording started. The last listing is remembered instead, and opening Settings or running `dikte devices` takes a fresh one. And a fourth thing, which is about what the interface says rather than what it does: whether the far side of a meeting can be captured at all is now an entry in `audio.Sound` instead of being read off an empty device list. The two are not the same answer. An empty list on Linux means pactl is not installed, which a user can go and fix; False on Windows means there is no such device and no driver that would add one. The Meeting tab says so under the empty box, and starting a meeting says it instead of sending somebody to Settings to pick from a list that will never have anything in it.
This commit is contained in:
@@ -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=<name>` 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",
|
||||
)
|
||||
|
||||
@@ -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.":
|
||||
|
||||
@@ -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 "
|
||||
|
||||
+113
-11
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user