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:
2026-08-16 10:28:43 +03:00
parent 191eef8f8d
commit 8c62795b8b
5 changed files with 250 additions and 30 deletions
+84 -19
View File
@@ -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",
)