mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
Add a Mac as the third system, beside Wayland and X11
Dikte already chose its clipboard programs once instead of in every function; macOS joins that table rather than adding a branch to each one. A Mac copies through pbcopy and presses Cmd+V straight into CoreGraphics, records through AVFoundation, and asks Carbon for its global shortcuts. The three tables are paste.Desktop, audio.Sound, and the pair of predicates in hotkey.py. Each reads sys.platform inside the chooser, so a test can stand somewhere else: 697 of the 737 tests now run on any machine, the Wayland and X11 halves included, and the suite passes whole whichever system it is run on. Two things a Mac does not have needed saying rather than pretending: there is no shortcut registry to install into, so Settings offers no Install button and the listener is the mechanism instead of a fallback; and nothing is offered as the sound the speakers are playing, so a meeting needs BlackHole or Loopback and says so. The KDE-only labels around them were already wrong on GNOME, and now name whichever desktop is there. Co-authored-by: firat <[email protected]>
This commit is contained in:
@@ -1,19 +1,27 @@
|
||||
"""Raw PCM capture with a live level meter.
|
||||
|
||||
Dictation records one source through pw-record. A meeting records two of them at
|
||||
once, the microphone and what comes out of the speakers, and for that it goes
|
||||
through ffmpeg instead: one process reading both devices and merging them into
|
||||
the two channels of a single stream, which is the only way the two stay aligned
|
||||
with each other over an hour.
|
||||
Dictation records one source. A meeting records two of them at once, the
|
||||
microphone and what comes out of the speakers, and for that it goes through
|
||||
ffmpeg: one process reading both devices and merging them into the two channels
|
||||
of a single stream, which is the only way the two stay aligned with each other
|
||||
over an hour.
|
||||
|
||||
Which programs do the capturing is a property of the machine, not of the code
|
||||
above: PulseAudio or PipeWire on Linux, AVFoundation through ffmpeg on macOS.
|
||||
They are gathered into one group each near the bottom of this file, and a
|
||||
chooser picks between them.
|
||||
"""
|
||||
|
||||
import array
|
||||
import collections
|
||||
import json
|
||||
import math
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import signal
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import wave
|
||||
@@ -57,9 +65,7 @@ class Recorder(QObject):
|
||||
return
|
||||
cmd = recording_command(target)
|
||||
if not cmd:
|
||||
self.failed.emit(t(
|
||||
"No audio recorder found. Install pulseaudio-utils or pipewire-audio."
|
||||
))
|
||||
self.failed.emit(t(sound().missing))
|
||||
return
|
||||
|
||||
try:
|
||||
@@ -175,36 +181,13 @@ def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
|
||||
|
||||
|
||||
def recording_command(target=""):
|
||||
"""Return a raw-s16 capture command for the sound server on this desktop.
|
||||
"""A raw-s16 capture command for the sound system on this machine."""
|
||||
return sound().record(target)
|
||||
|
||||
parec works with both PulseAudio and PipeWire's PulseAudio compatibility
|
||||
service, and its source names are the same ones shown by list_sources().
|
||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||
"""
|
||||
if shutil.which("parec"):
|
||||
cmd = [
|
||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16le",
|
||||
# Left alone, parec holds about two seconds before handing anything
|
||||
# over, and then hands over all of it at once: the level meter sits
|
||||
# still and jumps, and the tail of a recording can be lost on the
|
||||
# way out. A chunk of the meter is the unit the rest of this file
|
||||
# is measured in, so ask for that.
|
||||
f"--latency-msec={CHUNK_LATENCY_MS}",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--device={target}")
|
||||
return cmd
|
||||
if shutil.which("pw-record"):
|
||||
cmd = [
|
||||
"pw-record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--target={target}")
|
||||
cmd.append("-")
|
||||
return cmd
|
||||
return []
|
||||
|
||||
def meeting_command(mic_target, system_target):
|
||||
"""One ffmpeg reading both devices and merging them into two channels."""
|
||||
return sound().meeting(mic_target, system_target)
|
||||
|
||||
|
||||
class MeetingRecorder(QObject):
|
||||
@@ -251,18 +234,7 @@ class MeetingRecorder(QObject):
|
||||
"Pick one in Settings → Meeting."))
|
||||
return
|
||||
|
||||
merge = (
|
||||
"[0:a]aresample={rate}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];"
|
||||
"[1:a]aresample={rate}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];"
|
||||
"[m][s]amerge=inputs=2[out]"
|
||||
).format(rate=RATE)
|
||||
cmd = [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", system_target,
|
||||
"-filter_complex", merge, "-map", "[out]",
|
||||
"-f", "s16le", "-ar", str(RATE), "-",
|
||||
]
|
||||
cmd = meeting_command(mic_target, system_target)
|
||||
|
||||
try:
|
||||
os.makedirs(os.path.dirname(path), exist_ok=True)
|
||||
@@ -439,7 +411,61 @@ def _peak(samples):
|
||||
return min(1.0, max(abs(min(samples)), abs(max(samples))) / 32768.0)
|
||||
|
||||
|
||||
def _sources():
|
||||
# --- the sound system, one group per machine -------------------------------
|
||||
|
||||
# Both meeting commands merge the same way: each input down to mono at our own
|
||||
# rate, then the two of them into the left and right of one stream.
|
||||
MERGE_FILTER = (
|
||||
f"[0:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[m];"
|
||||
f"[1:a]aresample={RATE}:async=1,aformat=sample_fmts=s16:channel_layouts=mono[s];"
|
||||
"[m][s]amerge=inputs=2[out]"
|
||||
)
|
||||
|
||||
|
||||
def _pulse_record(target):
|
||||
"""parec, or pw-record where PulseAudio's tools were left out.
|
||||
|
||||
parec works with both PulseAudio and PipeWire's PulseAudio compatibility
|
||||
service, and its source names are the same ones shown by list_sources().
|
||||
Keep pw-record as the fallback for minimal native-PipeWire installations.
|
||||
"""
|
||||
if shutil.which("parec"):
|
||||
cmd = [
|
||||
"parec", "--record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16le",
|
||||
# Left alone, parec holds about two seconds before handing anything
|
||||
# over, and then hands over all of it at once: the level meter sits
|
||||
# still and jumps, and the tail of a recording can be lost on the
|
||||
# way out. A chunk of the meter is the unit the rest of this file
|
||||
# is measured in, so ask for that.
|
||||
f"--latency-msec={CHUNK_LATENCY_MS}",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--device={target}")
|
||||
return cmd
|
||||
if shutil.which("pw-record"):
|
||||
cmd = [
|
||||
"pw-record", "--raw", f"--rate={RATE}",
|
||||
f"--channels={CHANNELS}", "--format=s16",
|
||||
]
|
||||
if target:
|
||||
cmd.append(f"--target={target}")
|
||||
cmd.append("-")
|
||||
return cmd
|
||||
return []
|
||||
|
||||
|
||||
def _pulse_meeting(mic_target, system_target):
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", mic_target or "default",
|
||||
"-f", "pulse", "-thread_queue_size", "4096", "-i", system_target,
|
||||
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
||||
"-f", "s16le", "-ar", str(RATE), "-",
|
||||
]
|
||||
|
||||
|
||||
def _pactl_sources():
|
||||
if not shutil.which("pactl"):
|
||||
return []
|
||||
try:
|
||||
@@ -452,30 +478,23 @@ def _sources():
|
||||
return []
|
||||
|
||||
|
||||
def list_sources():
|
||||
"""[(name, description)] for every real input source."""
|
||||
def _pulse_inputs():
|
||||
return [
|
||||
(src.get("name", ""), src.get("description") or src.get("name", ""))
|
||||
for src in _sources()
|
||||
for src in _pactl_sources()
|
||||
if not src.get("name", "").endswith(".monitor")
|
||||
]
|
||||
|
||||
|
||||
def list_monitors():
|
||||
"""[(name, description)] for the monitor of every output.
|
||||
|
||||
Recording a monitor is recording whatever is being played, which in a
|
||||
meeting is the other participants and nothing of your own microphone.
|
||||
"""
|
||||
def _pulse_outputs():
|
||||
return [
|
||||
(src.get("name", ""), src.get("description") or src.get("name", ""))
|
||||
for src in _sources()
|
||||
for src in _pactl_sources()
|
||||
if src.get("name", "").endswith(".monitor")
|
||||
]
|
||||
|
||||
|
||||
def default_monitor():
|
||||
"""The monitor of the output sound is currently going to, or ''."""
|
||||
def _pulse_default_output():
|
||||
if not shutil.which("pactl"):
|
||||
return ""
|
||||
try:
|
||||
@@ -488,5 +507,130 @@ def default_monitor():
|
||||
if not sink:
|
||||
return ""
|
||||
monitor = f"{sink}.monitor"
|
||||
names = {name for name, _ in list_monitors()}
|
||||
names = {name for name, _ in _pulse_outputs()}
|
||||
return monitor if not names or monitor in names else ""
|
||||
|
||||
|
||||
# macOS hands out no monitor of its own: what the speakers are playing is not
|
||||
# an input, and the only way to record it is a driver that pretends to be one.
|
||||
# These are the three people install.
|
||||
LOOPBACK_DEVICES = ("blackhole", "loopback", "soundflower")
|
||||
|
||||
|
||||
def _avfoundation_record(target):
|
||||
if not shutil.which("ffmpeg"):
|
||||
return []
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
# AVFoundation names an input "video:audio", so the empty half in front
|
||||
# of the colon is what says this recording has no picture in it.
|
||||
"-f", "avfoundation", "-i", f":{target or 'default'}",
|
||||
"-ac", str(CHANNELS), "-ar", str(RATE), "-f", "s16le", "-",
|
||||
]
|
||||
|
||||
|
||||
def _avfoundation_meeting(mic_target, system_target):
|
||||
return [
|
||||
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
||||
"-thread_queue_size", "4096",
|
||||
"-f", "avfoundation", "-i", f":{mic_target or 'default'}",
|
||||
"-thread_queue_size", "4096",
|
||||
"-f", "avfoundation", "-i", f":{system_target}",
|
||||
"-filter_complex", MERGE_FILTER, "-map", "[out]",
|
||||
"-f", "s16le", "-ar", str(RATE), "-",
|
||||
]
|
||||
|
||||
|
||||
def _avfoundation_inputs():
|
||||
"""[(index, name)] for every capture device AVFoundation offers.
|
||||
|
||||
The index is what the recorder is given, because that is what ffmpeg takes;
|
||||
it changes when devices are plugged in, which is why the name is shown.
|
||||
"""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return []
|
||||
try:
|
||||
# Listing devices is not a thing ffmpeg can do without an input, so it
|
||||
# is asked for one it cannot open: the list comes out on stderr and the
|
||||
# command then fails, which is the documented way of doing this.
|
||||
result = subprocess.run(
|
||||
["ffmpeg", "-hide_banner", "-f", "avfoundation",
|
||||
"-list_devices", "true", "-i", ""],
|
||||
capture_output=True, text=True, timeout=8, check=False,
|
||||
)
|
||||
except (subprocess.SubprocessError, OSError):
|
||||
return []
|
||||
|
||||
devices, listing = [], False
|
||||
for line in result.stderr.splitlines():
|
||||
if "AVFoundation audio devices:" in line:
|
||||
listing = True
|
||||
continue
|
||||
if not listing:
|
||||
continue
|
||||
match = re.search(r"\[(\d+)\]\s+(.+)$", line)
|
||||
if match:
|
||||
devices.append((match.group(1), match.group(2).strip()))
|
||||
return devices
|
||||
|
||||
|
||||
def _avfoundation_default_output():
|
||||
for name, description in _avfoundation_inputs():
|
||||
if any(word in description.lower() for word in LOOPBACK_DEVICES):
|
||||
return name
|
||||
return ""
|
||||
|
||||
|
||||
Sound = collections.namedtuple(
|
||||
"Sound",
|
||||
# How to capture one source and two at once, 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",
|
||||
)
|
||||
|
||||
PULSE = Sound(
|
||||
record=_pulse_record,
|
||||
meeting=_pulse_meeting,
|
||||
inputs=_pulse_inputs,
|
||||
outputs=_pulse_outputs,
|
||||
default_output=_pulse_default_output,
|
||||
missing="No audio recorder found. Install pulseaudio-utils or pipewire-audio.",
|
||||
)
|
||||
|
||||
COREAUDIO = Sound(
|
||||
record=_avfoundation_record,
|
||||
meeting=_avfoundation_meeting,
|
||||
inputs=_avfoundation_inputs,
|
||||
# Every macOS capture device is offered as the far side of a meeting, the
|
||||
# loopback driver among them: there is no way to tell them apart, and an
|
||||
# empty list would leave nothing to pick.
|
||||
outputs=_avfoundation_inputs,
|
||||
default_output=_avfoundation_default_output,
|
||||
missing="ffmpeg not found. Install it with: brew install ffmpeg",
|
||||
)
|
||||
|
||||
|
||||
def sound():
|
||||
"""The programs this machine records through."""
|
||||
return COREAUDIO if sys.platform == "darwin" else PULSE
|
||||
|
||||
|
||||
def list_sources():
|
||||
"""[(name, description)] for every real input source."""
|
||||
return sound().inputs()
|
||||
|
||||
|
||||
def list_monitors():
|
||||
"""[(name, description)] for whatever can be recorded as the other side.
|
||||
|
||||
On Linux that is the monitor of an output, and recording it is recording
|
||||
whatever is being played: in a meeting the other participants, and nothing
|
||||
of your own microphone.
|
||||
"""
|
||||
return sound().outputs()
|
||||
|
||||
|
||||
def default_monitor():
|
||||
"""The device the far side of a meeting comes from, or ''."""
|
||||
return sound().default_output()
|
||||
|
||||
Reference in New Issue
Block a user