mirror of
https://github.com/yusufipk/dikte.git
synced 2026-09-11 10:56:10 +00:00
812 lines
27 KiB
Python
812 lines
27 KiB
Python
"""Raw PCM capture with a live level meter.
|
|
|
|
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. macOS uses one ffmpeg process per AVFoundation
|
|
device: two AVFoundation sessions in one process silently starve one another.
|
|
"""
|
|
|
|
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
|
|
|
|
from PyQt6.QtCore import QObject, pyqtSignal
|
|
|
|
from i18n import t
|
|
|
|
RATE = 16000
|
|
CHANNELS = 1
|
|
SAMPLE_WIDTH = 2 # s16
|
|
CHUNK_FRAMES = 1024
|
|
CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS
|
|
CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000)
|
|
MIN_FRAMES = int(RATE * 0.25)
|
|
|
|
|
|
class Recorder(QObject):
|
|
"""Runs the available sound-server recorder and reads raw PCM from stdout."""
|
|
|
|
level = pyqtSignal(float) # 0.0 - 1.0, for the waveform
|
|
stopped = pyqtSignal(str, float, object) # wav path, duration (s), per-chunk RMS
|
|
failed = pyqtSignal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._proc = None
|
|
self._thread = None
|
|
self._buffer = bytearray()
|
|
self._rms = []
|
|
self._cancelled = False
|
|
self._stopping = False
|
|
self._lock = threading.Lock()
|
|
|
|
@property
|
|
def active(self):
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
def start(self, target="", max_seconds=300):
|
|
if self.active:
|
|
return
|
|
try:
|
|
cmd = recording_command(target)
|
|
except AudioDeviceError as exc:
|
|
self.failed.emit(str(exc))
|
|
return
|
|
if not cmd:
|
|
self.failed.emit(t(sound().missing))
|
|
return
|
|
|
|
try:
|
|
self._proc = subprocess.Popen(
|
|
cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, bufsize=0
|
|
)
|
|
except OSError as exc:
|
|
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
|
return
|
|
|
|
self._buffer = bytearray()
|
|
self._rms = []
|
|
self._cancelled = False
|
|
self._stopping = False
|
|
self._max_bytes = int(max_seconds * RATE * SAMPLE_WIDTH * CHANNELS)
|
|
self._thread = threading.Thread(target=self._pump, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _pump(self):
|
|
proc = self._proc
|
|
stdout = proc.stdout
|
|
try:
|
|
while True:
|
|
chunk = stdout.read(CHUNK_BYTES)
|
|
if not chunk:
|
|
break
|
|
peak, rms = chunk_levels(chunk)
|
|
with self._lock:
|
|
self._buffer.extend(chunk)
|
|
self._rms.append(rms)
|
|
too_long = len(self._buffer) >= self._max_bytes
|
|
self.level.emit(peak)
|
|
if too_long:
|
|
self._terminate()
|
|
break
|
|
except (OSError, ValueError):
|
|
pass
|
|
# Nobody asked it to end and it captured nothing: the recorder is not
|
|
# installed properly, or the device was refused. Said out loud here,
|
|
# because stop() would otherwise report it as a recording that was too
|
|
# short, which sends the user looking in the wrong place.
|
|
with self._lock:
|
|
captured = bool(self._buffer)
|
|
if self._stopping or self._cancelled or captured:
|
|
return
|
|
try:
|
|
detail = proc.stderr.read().decode("utf-8", "replace").strip()
|
|
except (AttributeError, OSError):
|
|
detail = ""
|
|
self.failed.emit(t(
|
|
"Audio recorder stopped before receiving sound: {error}",
|
|
error=detail or f"exit code {proc.returncode}",
|
|
))
|
|
|
|
def _terminate(self):
|
|
self._stopping = True
|
|
proc = self._proc
|
|
if proc and proc.poll() is None:
|
|
try:
|
|
proc.send_signal(signal.SIGINT)
|
|
proc.wait(timeout=1.5)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
try:
|
|
proc.kill()
|
|
except OSError:
|
|
pass
|
|
|
|
def cancel(self):
|
|
self._cancelled = True
|
|
self._terminate()
|
|
if self._thread:
|
|
self._thread.join(timeout=2)
|
|
self._thread = None
|
|
self._proc = None
|
|
with self._lock:
|
|
self._buffer = bytearray()
|
|
|
|
def stop(self):
|
|
"""End the recording and write the WAV file."""
|
|
if not self._proc:
|
|
return
|
|
self._terminate()
|
|
if self._thread:
|
|
self._thread.join(timeout=2)
|
|
self._thread = None
|
|
self._proc = None
|
|
|
|
with self._lock:
|
|
pcm = bytes(self._buffer)
|
|
rms = list(self._rms)
|
|
self._buffer = bytearray()
|
|
|
|
if self._cancelled:
|
|
return
|
|
|
|
frames = len(pcm) // (SAMPLE_WIDTH * CHANNELS)
|
|
if frames < MIN_FRAMES: # a stray keypress, not speech
|
|
self.failed.emit(t("Recording too short, speak for at least 0.3 s"))
|
|
return
|
|
|
|
path = write_wav(pcm)
|
|
self.stopped.emit(path, frames / RATE, rms)
|
|
|
|
|
|
def write_wav(pcm, rate=RATE, channels=CHANNELS, width=SAMPLE_WIDTH):
|
|
fd, path = tempfile.mkstemp(prefix="dikte-", suffix=".wav")
|
|
with open(fd, "wb") as raw, wave.open(raw, "wb") as wav:
|
|
wav.setnchannels(channels)
|
|
wav.setsampwidth(width)
|
|
wav.setframerate(rate)
|
|
wav.writeframes(pcm)
|
|
return path
|
|
|
|
|
|
def recording_command(target=""):
|
|
"""A raw-s16 capture command for the sound system on this machine."""
|
|
return sound().record(target)
|
|
|
|
|
|
def meeting_commands(mic_target, system_target):
|
|
"""The capture processes that produce one stereo meeting stream.
|
|
|
|
PulseAudio can keep both inputs in one ffmpeg process. AVFoundation cannot:
|
|
on a real Mac its two sessions silently starve the microphone, so each Mac
|
|
device is captured and clock-corrected by its own process. MeetingRecorder
|
|
interleaves those two mono streams after that.
|
|
"""
|
|
if sound() is COREAUDIO:
|
|
return [
|
|
_avfoundation_meeting_capture(mic_target),
|
|
_avfoundation_meeting_capture(system_target),
|
|
]
|
|
return [sound().meeting(mic_target, system_target)]
|
|
|
|
|
|
class AudioDeviceError(RuntimeError):
|
|
"""A saved capture device can no longer be selected safely."""
|
|
|
|
|
|
class MeetingRecorder(QObject):
|
|
"""Microphone and speaker output into one stereo file: left is you, right is
|
|
everyone else.
|
|
|
|
Who said what then needs no guessing at all, because the two voices never
|
|
shared a channel to begin with. The recording is written to disk as it
|
|
arrives rather than held in memory, so length is not a problem and a crash
|
|
costs the tail of the meeting instead of all of it.
|
|
"""
|
|
|
|
levels = pyqtSignal(float, float) # mine, theirs
|
|
stopped = pyqtSignal(str, float) # wav path, duration (s)
|
|
died = pyqtSignal() # ffmpeg quit on its own
|
|
failed = pyqtSignal(str)
|
|
|
|
def __init__(self, parent=None):
|
|
super().__init__(parent)
|
|
self._proc = None
|
|
self._procs = []
|
|
self._thread = None
|
|
self._wav = None
|
|
self._logs = []
|
|
self._path = ""
|
|
self._frames = 0
|
|
self._mic_zero_frames = 0
|
|
self._split_inputs = False
|
|
self._cancelled = False
|
|
self._stopping = False
|
|
self._lock = threading.Lock()
|
|
|
|
@property
|
|
def active(self):
|
|
return self._thread is not None and self._thread.is_alive()
|
|
|
|
def start(self, path, mic_target="", system_target="", max_seconds=14400):
|
|
if self.active:
|
|
return
|
|
if not shutil.which("ffmpeg"):
|
|
self.failed.emit(t("ffmpeg not found. Install it to record a meeting."))
|
|
return
|
|
if not system_target:
|
|
system_target = default_monitor()
|
|
if not system_target:
|
|
self.failed.emit(t("Could not work out which speaker output to record. "
|
|
"Pick one in Settings → Meeting."))
|
|
return
|
|
|
|
try:
|
|
commands = meeting_commands(mic_target, system_target)
|
|
except AudioDeviceError as exc:
|
|
self.failed.emit(str(exc))
|
|
return
|
|
|
|
try:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
self._wav = wave.open(path, "wb")
|
|
self._wav.setnchannels(2)
|
|
self._wav.setsampwidth(SAMPLE_WIDTH)
|
|
self._wav.setframerate(RATE)
|
|
# ffmpeg keeps talking to stderr for as long as it runs; a pipe
|
|
# nobody drains would eventually block it, so it writes to a file.
|
|
self._logs = [tempfile.TemporaryFile() for _ in commands]
|
|
self._procs = []
|
|
for command, log in zip(commands, self._logs):
|
|
self._procs.append(subprocess.Popen(
|
|
command, stdout=subprocess.PIPE, stderr=log, bufsize=0
|
|
))
|
|
self._proc = self._procs[0]
|
|
except (OSError, wave.Error) as exc:
|
|
self._terminate_processes()
|
|
self._proc = None
|
|
self._procs = []
|
|
self._close_file()
|
|
self._drop_log()
|
|
try:
|
|
os.unlink(path) # an empty header nobody will ever read
|
|
except OSError:
|
|
pass
|
|
self.failed.emit(t("Could not start recording: {error}", error=exc))
|
|
return
|
|
|
|
self._path = path
|
|
self._frames = 0
|
|
self._mic_zero_frames = 0
|
|
self._split_inputs = len(self._procs) == 2
|
|
self._cancelled = False
|
|
self._stopping = False
|
|
self._max_frames = int(max_seconds * RATE)
|
|
self._thread = threading.Thread(target=self._pump, daemon=True)
|
|
self._thread.start()
|
|
|
|
def _pump(self):
|
|
if self._split_inputs:
|
|
self._pump_split()
|
|
else:
|
|
self._pump_merged()
|
|
# Nobody asked it to end: the sound device went away, or ffmpeg fell
|
|
# over. An hour into a meeting that has to be said out loud rather than
|
|
# discovered afterwards.
|
|
if not self._stopping:
|
|
self.died.emit()
|
|
|
|
def _pump_merged(self):
|
|
stdout = self._procs[0].stdout
|
|
block = CHUNK_FRAMES * SAMPLE_WIDTH * 2
|
|
try:
|
|
while True:
|
|
chunk = stdout.read(block)
|
|
if not chunk:
|
|
break
|
|
if not self._write_chunk(chunk):
|
|
break
|
|
except (OSError, ValueError, wave.Error):
|
|
pass
|
|
|
|
def _pump_split(self):
|
|
left = self._procs[0].stdout
|
|
right = self._procs[1].stdout
|
|
block = CHUNK_FRAMES * SAMPLE_WIDTH
|
|
try:
|
|
while True:
|
|
mine = _read_exact(left, block)
|
|
theirs = _read_exact(right, block)
|
|
if not mine or not theirs:
|
|
break
|
|
frames = min(len(mine), len(theirs)) // SAMPLE_WIDTH
|
|
mine = mine[:frames * SAMPLE_WIDTH]
|
|
theirs = theirs[:frames * SAMPLE_WIDTH]
|
|
self._mic_zero_frames += _zero_samples(mine)
|
|
if not self._write_chunk(interleave_mono(mine, theirs)):
|
|
break
|
|
except (OSError, ValueError, wave.Error):
|
|
pass
|
|
|
|
def _write_chunk(self, chunk):
|
|
mine, theirs = stereo_levels(chunk)
|
|
with self._lock:
|
|
if self._wav is None:
|
|
return False
|
|
self._wav.writeframes(chunk)
|
|
self._frames += len(chunk) // (SAMPLE_WIDTH * 2)
|
|
too_long = self._frames >= self._max_frames
|
|
self.levels.emit(mine, theirs)
|
|
if too_long:
|
|
self._terminate()
|
|
return False
|
|
return True
|
|
|
|
def _terminate(self):
|
|
self._stopping = True
|
|
self._terminate_processes()
|
|
|
|
def _terminate_processes(self):
|
|
running = [proc for proc in self._procs if proc.poll() is None]
|
|
for proc in running:
|
|
try:
|
|
proc.send_signal(signal.SIGINT)
|
|
except OSError:
|
|
pass
|
|
for proc in running:
|
|
try:
|
|
proc.wait(timeout=2)
|
|
except (subprocess.TimeoutExpired, OSError):
|
|
try:
|
|
proc.kill()
|
|
except OSError:
|
|
pass
|
|
|
|
def _close_file(self):
|
|
with self._lock:
|
|
wav, self._wav = self._wav, None
|
|
if wav is not None:
|
|
try:
|
|
wav.close()
|
|
except (OSError, wave.Error):
|
|
pass
|
|
|
|
def _error_tail(self):
|
|
tails = []
|
|
for log in self._logs:
|
|
try:
|
|
log.seek(0)
|
|
text = log.read().decode("utf-8", "replace").strip()
|
|
except OSError:
|
|
continue
|
|
lines = [line for line in text.splitlines() if line.strip()]
|
|
if lines:
|
|
tails.append(lines[-1])
|
|
return " | ".join(tails)
|
|
|
|
def _finish_process(self):
|
|
self._terminate()
|
|
if self._thread:
|
|
self._thread.join(timeout=3)
|
|
self._thread = None
|
|
codes = [proc.poll() for proc in self._procs]
|
|
code = next((value for value in codes if value), 0)
|
|
self._proc = None
|
|
self._procs = []
|
|
self._close_file()
|
|
return code
|
|
|
|
def cancel(self):
|
|
self._cancelled = True
|
|
self._finish_process()
|
|
self._drop_log()
|
|
try:
|
|
os.unlink(self._path)
|
|
except OSError:
|
|
pass
|
|
|
|
def stop(self):
|
|
if not self._proc:
|
|
return
|
|
# The count is read after the join: the pump thread is still appending
|
|
# the last blocks up to the moment it ends.
|
|
code = self._finish_process()
|
|
frames = self._frames
|
|
if self._cancelled:
|
|
self._drop_log()
|
|
return
|
|
|
|
# SIGINT is how the recording ends, and ffmpeg reports being interrupted
|
|
# as a failure; only complain when nothing was captured either.
|
|
if frames < MIN_FRAMES:
|
|
tail = self._error_tail()
|
|
self._drop_log()
|
|
try:
|
|
os.unlink(self._path)
|
|
except OSError:
|
|
pass
|
|
self.failed.emit(
|
|
t("Nothing was recorded: {error}", error=tail or f"ffmpeg → {code}")
|
|
if tail or code else t("Recording too short, speak for at least 0.3 s")
|
|
)
|
|
return
|
|
if (self._split_inputs and frames >= RATE * 10
|
|
and self._mic_zero_frames / frames > 0.5):
|
|
empty = round(self._mic_zero_frames / frames * 100)
|
|
self._drop_log()
|
|
try:
|
|
os.unlink(self._path)
|
|
except OSError:
|
|
pass
|
|
self.failed.emit(t(
|
|
"The macOS microphone stopped delivering audio ({percent}% was "
|
|
"empty). The unusable recording was discarded; reconnect the "
|
|
"device and try again.", percent=empty,
|
|
))
|
|
return
|
|
self._drop_log()
|
|
self.stopped.emit(self._path, frames / RATE)
|
|
|
|
def _drop_log(self):
|
|
for log in self._logs:
|
|
try:
|
|
log.close()
|
|
except OSError:
|
|
pass
|
|
self._logs = []
|
|
|
|
|
|
def chunk_levels(chunk):
|
|
"""(peak, rms) in 0..1. Peak drives the waveform, RMS drives the silence check."""
|
|
samples = array.array("h")
|
|
usable = len(chunk) - (len(chunk) % 2)
|
|
if usable <= 0:
|
|
return 0.0, 0.0
|
|
samples.frombytes(chunk[:usable])
|
|
peak = max(abs(min(samples)), abs(max(samples))) / 32768.0
|
|
rms = math.sqrt(sum(s * s for s in samples) / len(samples)) / 32768.0
|
|
return min(1.0, peak), min(1.0, rms)
|
|
|
|
|
|
def stereo_levels(chunk):
|
|
"""(left peak, right peak) in 0..1 from interleaved stereo s16."""
|
|
samples = array.array("h")
|
|
usable = len(chunk) - (len(chunk) % 4)
|
|
if usable <= 0:
|
|
return 0.0, 0.0
|
|
samples.frombytes(chunk[:usable])
|
|
left, right = samples[0::2], samples[1::2]
|
|
return _peak(left), _peak(right)
|
|
|
|
|
|
def interleave_mono(left, right):
|
|
"""Two equally long mono-s16 buffers into one stereo-s16 buffer."""
|
|
left_samples = array.array("h")
|
|
right_samples = array.array("h")
|
|
left_samples.frombytes(left[:len(left) - len(left) % SAMPLE_WIDTH])
|
|
right_samples.frombytes(right[:len(right) - len(right) % SAMPLE_WIDTH])
|
|
frames = min(len(left_samples), len(right_samples))
|
|
stereo_samples = array.array("h")
|
|
stereo_samples.extend(
|
|
sample for pair in zip(left_samples[:frames], right_samples[:frames])
|
|
for sample in pair
|
|
)
|
|
return stereo_samples.tobytes()
|
|
|
|
|
|
def _read_exact(stream, size):
|
|
"""Read one meter-sized block, tolerating short unbuffered pipe reads."""
|
|
out = bytearray()
|
|
while len(out) < size:
|
|
chunk = stream.read(size - len(out))
|
|
if not chunk:
|
|
break
|
|
out.extend(chunk)
|
|
return bytes(out)
|
|
|
|
|
|
def _zero_samples(chunk):
|
|
samples = array.array("h")
|
|
samples.frombytes(chunk[:len(chunk) - len(chunk) % SAMPLE_WIDTH])
|
|
return sum(sample == 0 for sample in samples)
|
|
|
|
|
|
def _peak(samples):
|
|
if not samples:
|
|
return 0.0
|
|
return min(1.0, max(abs(min(samples)), abs(max(samples))) / 32768.0)
|
|
|
|
|
|
# --- 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", *_pw_record_raw_option(), f"--rate={RATE}",
|
|
f"--channels={CHANNELS}", "--format=s16",
|
|
]
|
|
if target:
|
|
cmd.append(f"--target={target}")
|
|
cmd.append("-")
|
|
return cmd
|
|
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",
|
|
"-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:
|
|
out = subprocess.run(
|
|
["pactl", "-f", "json", "list", "sources"],
|
|
capture_output=True, text=True, timeout=5, check=True,
|
|
).stdout
|
|
return json.loads(out)
|
|
except (subprocess.SubprocessError, OSError, json.JSONDecodeError):
|
|
return []
|
|
|
|
|
|
def _pulse_inputs():
|
|
return [
|
|
(src.get("name", ""), src.get("description") or src.get("name", ""))
|
|
for src in _pactl_sources()
|
|
if not src.get("name", "").endswith(".monitor")
|
|
]
|
|
|
|
|
|
def _pulse_outputs():
|
|
return [
|
|
(src.get("name", ""), src.get("description") or src.get("name", ""))
|
|
for src in _pactl_sources()
|
|
if src.get("name", "").endswith(".monitor")
|
|
]
|
|
|
|
|
|
def _pulse_default_output():
|
|
if not shutil.which("pactl"):
|
|
return ""
|
|
try:
|
|
sink = subprocess.run(
|
|
["pactl", "get-default-sink"],
|
|
capture_output=True, text=True, timeout=5, check=True,
|
|
).stdout.strip()
|
|
except (subprocess.SubprocessError, OSError):
|
|
return ""
|
|
if not sink:
|
|
return ""
|
|
monitor = f"{sink}.monitor"
|
|
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 []
|
|
target = _resolve_avfoundation_target(target)
|
|
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_capture(target):
|
|
target = _resolve_avfoundation_target(target)
|
|
return [
|
|
"ffmpeg", "-hide_banner", "-nostdin", "-loglevel", "error",
|
|
"-thread_queue_size", "4096",
|
|
"-f", "avfoundation", "-i", f":{target or 'default'}",
|
|
"-af", (f"aresample={RATE}:async=1:first_pts=0,"
|
|
"aformat=sample_fmts=s16:channel_layouts=mono"),
|
|
"-f", "s16le", "-ar", str(RATE), "-ac", "1", "-",
|
|
]
|
|
|
|
|
|
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_named_inputs():
|
|
"""Stable settings values: the name is saved, never the moving index."""
|
|
return [(description, description)
|
|
for _index, description in _avfoundation_inputs()]
|
|
|
|
|
|
def _resolve_avfoundation_target(target):
|
|
"""Resolve a stored device name to its current, positional ffmpeg index."""
|
|
if not target or target == "default":
|
|
return "default"
|
|
if str(target).isdigit():
|
|
raise AudioDeviceError(t(
|
|
"The saved macOS audio device uses an old numeric index. Open "
|
|
"Settings and select the device again before recording."
|
|
))
|
|
matches = [index for index, description in _avfoundation_inputs()
|
|
if description == target]
|
|
if not matches:
|
|
raise AudioDeviceError(t(
|
|
"The saved macOS audio device is no longer connected: {device}. "
|
|
"Open Settings and select another device.", device=target,
|
|
))
|
|
if len(matches) > 1:
|
|
raise AudioDeviceError(t(
|
|
"More than one macOS audio device is named {device}. Disconnect the "
|
|
"duplicate or choose a different device.", device=target,
|
|
))
|
|
return matches[0]
|
|
|
|
|
|
def _avfoundation_default_output():
|
|
for _index, description in _avfoundation_inputs():
|
|
if any(word in description.lower() for word in LOOPBACK_DEVICES):
|
|
return description
|
|
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=None, # two separate capture processes; see meeting_commands()
|
|
inputs=_avfoundation_named_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_named_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()
|