diff --git a/audio.py b/audio.py index 55beb50..511bdf1 100644 --- a/audio.py +++ b/audio.py @@ -2,9 +2,11 @@ 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. +ffmpeg. PulseAudio hands both devices to a single process, which merges them +into the two channels of one stream and keeps them aligned itself. AVFoundation +cannot be asked the same: two of its sessions inside one process starve each +other, so a Mac captures each device on its own and the two mono streams are +interleaved here as they arrive. 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. @@ -17,6 +19,7 @@ import collections import json import math import os +import queue import re import shutil import signal @@ -38,6 +41,21 @@ CHUNK_BYTES = CHUNK_FRAMES * SAMPLE_WIDTH * CHANNELS CHUNK_LATENCY_MS = round(CHUNK_FRAMES / RATE * 1000) MIN_FRAMES = int(RATE * 0.25) +# A capture process hands over a block every CHUNK_LATENCY_MS. One that has said +# nothing for this long has stopped rather than fallen behind, and the meeting +# ends and says so instead of sitting on a read that will never return. +STALL_SECONDS = 5.0 +# Room for a whole stall of the other stream, so the side still delivering is +# never the one left waiting. +QUEUE_BLOCKS = int(STALL_SECONDS * RATE / CHUNK_FRAMES) + 8 + +# Exact zeroes are not quiet, they are nothing: a microphone that is really in +# the room has a noise floor. This much of a recording that long means it handed +# nothing over, which is worth saying once the meeting is over and nothing can +# be done about it any more. +QUIET_MIC_SECONDS = 10 +QUIET_MIC_SHARE = 0.5 + class Recorder(QObject): """Runs the available sound-server recorder and reads raw PCM from stdout.""" @@ -63,7 +81,11 @@ class Recorder(QObject): def start(self, target="", max_seconds=300): if self.active: return - cmd = recording_command(target) + 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 @@ -185,11 +207,21 @@ def recording_command(target=""): return sound().record(target) -def meeting_command(mic_target, system_target): - """One ffmpeg reading both devices and merging them into two channels.""" +def meeting_commands(mic_target, system_target): + """The capture processes that produce one stereo meeting stream. + + One of them on PulseAudio, which merges both inputs itself; one per device + on a Mac, because two AVFoundation sessions in a process starve each other. + Which of the two it is stays in the table with everything else the sound + system decides, and MeetingRecorder reads the count rather than the machine. + """ 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. @@ -203,16 +235,19 @@ class MeetingRecorder(QObject): levels = pyqtSignal(float, float) # mine, theirs stopped = pyqtSignal(str, float) # wav path, duration (s) died = pyqtSignal() # ffmpeg quit on its own + warned = pyqtSignal(str) # recorded, but something was wrong failed = pyqtSignal(str) def __init__(self, parent=None): super().__init__(parent) - self._proc = None + self._procs = [] self._thread = None self._wav = None - self._log = 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() @@ -234,7 +269,11 @@ class MeetingRecorder(QObject): "Pick one in Settings → Meeting.")) return - cmd = meeting_command(mic_target, system_target) + 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) @@ -244,11 +283,17 @@ class MeetingRecorder(QObject): 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._log = tempfile.TemporaryFile() - self._proc = subprocess.Popen( - cmd, stdout=subprocess.PIPE, stderr=self._log, bufsize=0 - ) + 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 + )) except (OSError, wave.Error) as exc: + # One of two capture processes may already be running, and a Mac + # left holding an open AVFoundation session records nothing else. + self._terminate_processes() + self._procs = [] self._close_file() self._drop_log() try: @@ -260,6 +305,8 @@ class MeetingRecorder(QObject): 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) @@ -267,38 +314,83 @@ class MeetingRecorder(QObject): self._thread.start() def _pump(self): - stdout = self._proc.stdout - block = CHUNK_FRAMES * SAMPLE_WIDTH * 2 - try: - while True: - chunk = stdout.read(block) - if not chunk: - break - mine, theirs = stereo_levels(chunk) - with self._lock: - if self._wav is None: - break - 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() - break - except (OSError, ValueError, wave.Error): - pass + 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): + # A reader thread per process. Taking turns on the two pipes from one + # thread would let a starved microphone hold up the far side too: its + # blocks would sit unread until the pipe filled and its ffmpeg stopped + # writing, and an hour of meeting would freeze with nothing said. Each + # stream is read as fast as it arrives, and a side that goes quiet for + # STALL_SECONDS ends the recording rather than hanging it. + block = CHUNK_FRAMES * SAMPLE_WIDTH + streams = [queue.Queue(maxsize=QUEUE_BLOCKS) for _ in self._procs] + for proc, blocks in zip(self._procs, streams): + threading.Thread(target=_read_blocks, daemon=True, + args=(proc.stdout, blocks, block)).start() + try: + while True: + mine = _next_block(streams[0]) + theirs = _next_block(streams[1]) + 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 - proc = self._proc - if proc and proc.poll() is None: + 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: @@ -316,23 +408,26 @@ class MeetingRecorder(QObject): pass def _error_tail(self): - if self._log is None: - return "" - try: - self._log.seek(0) - text = self._log.read().decode("utf-8", "replace").strip() - except OSError: - return "" - lines = [line for line in text.splitlines() if line.strip()] - return lines[-1] if lines else "" + 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 - code = self._proc.poll() if self._proc else 0 - self._proc = None + codes = [proc.poll() for proc in self._procs] + code = next((value for value in codes if value), 0) + self._procs = [] self._close_file() return code @@ -346,7 +441,7 @@ class MeetingRecorder(QObject): pass def stop(self): - if not self._proc: + if not self._procs: return # The count is read after the join: the pump thread is still appending # the last blocks up to the moment it ends. @@ -371,15 +466,27 @@ class MeetingRecorder(QObject): ) return self._drop_log() + # A microphone that handed nothing over costs the left channel, and the + # recording is kept anyway: the right one is everyone else, and an hour + # of them is worth more than an empty channel costs. Only the split + # capture can starve a device this way; one ffmpeg reading both cannot. + if (self._split_inputs and frames >= RATE * QUIET_MIC_SECONDS + and self._mic_zero_frames / frames > QUIET_MIC_SHARE): + self.warned.emit(t( + "The microphone handed over almost nothing ({percent}% of the " + "recording was empty), so your own side of the meeting will be " + "mostly missing. Check the device before the next one.", + percent=round(self._mic_zero_frames / frames * 100), + )) self.stopped.emit(self._path, frames / RATE) def _drop_log(self): - if self._log is not None: + for log in self._logs: try: - self._log.close() + log.close() except OSError: pass - self._log = None + self._logs = [] def chunk_levels(chunk): @@ -405,6 +512,62 @@ def stereo_levels(chunk): return _peak(left), _peak(right) +def interleave_mono(left, right): + """Two mono-s16 buffers into one stereo-s16 buffer, the shorter one setting + the length.""" + left_samples, right_samples = _samples(left), _samples(right) + frames = min(len(left_samples), len(right_samples)) + stereo_samples = array.array("h", bytes(frames * 2 * SAMPLE_WIDTH)) + stereo_samples[0::2] = left_samples[:frames] + stereo_samples[1::2] = right_samples[:frames] + return stereo_samples.tobytes() + + +def _read_blocks(stream, blocks, size): + """One stream's blocks onto its queue, ending with the empty one. + + A queue that stays full is the pump having given up on this recording, and + then there is nobody left to hand anything to. + """ + try: + while True: + block = _read_exact(stream, size) + blocks.put(block, timeout=STALL_SECONDS) + if not block: + return + except (OSError, ValueError, queue.Full): + pass + + +def _next_block(blocks): + """The next block of a stream, empty once it ends or falls silent.""" + try: + return blocks.get(timeout=STALL_SECONDS) + except queue.Empty: + return b"" + + +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): + return _samples(chunk).count(0) + + +def _samples(chunk): + samples = array.array("h") + samples.frombytes(chunk[:len(chunk) - len(chunk) % SAMPLE_WIDTH]) + return samples + + def _peak(samples): if not samples: return 0.0 @@ -413,8 +576,9 @@ def _peak(samples): # --- 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. +# How the one PulseAudio process merges: each input down to mono at our own +# rate, then the two of them into the left and right of one stream. A Mac does +# the first half per process and the second half itself, in interleave_mono(). 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];" @@ -478,13 +642,14 @@ def _pw_record_raw_option(): def _pulse_meeting(mic_target, system_target): - return [ + """One process for both devices: PulseAudio keeps them aligned itself.""" + 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(): @@ -542,6 +707,7 @@ 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 @@ -552,14 +718,26 @@ def _avfoundation_record(target): def _avfoundation_meeting(mic_target, system_target): + """A process per device, both names read off the same device listing. + + Asking ffmpeg what is plugged in costs a process of its own, and a listing + taken twice could renumber in between: the two targets have to be resolved + against the same one to name the same machine the user picked from. + """ + inputs = _avfoundation_inputs() + return [_avfoundation_meeting_capture(mic_target, inputs), + _avfoundation_meeting_capture(system_target, inputs)] + + +def _avfoundation_meeting_capture(target, inputs=None): + target = _resolve_avfoundation_target(target, inputs) 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), "-", + "-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", "-", ] @@ -596,18 +774,51 @@ def _avfoundation_inputs(): 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, inputs=None): + """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." + )) + if inputs is None: + inputs = _avfoundation_inputs() + matches = [index for index, description in 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 name, description in _avfoundation_inputs(): + for _index, description in _avfoundation_inputs(): if any(word in description.lower() for word in LOOPBACK_DEVICES): - return name + 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. + # 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", ) @@ -623,11 +834,11 @@ PULSE = Sound( COREAUDIO = Sound( record=_avfoundation_record, meeting=_avfoundation_meeting, - inputs=_avfoundation_inputs, + 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_inputs, + outputs=_avfoundation_named_inputs, default_output=_avfoundation_default_output, missing="ffmpeg not found. Install it with: brew install ffmpeg", ) diff --git a/dikte.py b/dikte.py index a0e8718..ee64b40 100755 --- a/dikte.py +++ b/dikte.py @@ -126,6 +126,7 @@ class Dikte: self.meeting_recorder.levels.connect(self._on_meeting_levels) self.meeting_recorder.stopped.connect(self._on_meeting_recorded) self.meeting_recorder.died.connect(self._on_meeting_died) + self.meeting_recorder.warned.connect(self._on_meeting_warning) self.meeting_recorder.failed.connect(self._on_meeting_error) self.meetings.progress.connect(self._on_meeting_progress) self.meetings.finished.connect(self._on_meeting_finished) @@ -735,6 +736,11 @@ class Dikte: self._settle(MEETING, {"ok": False, "error": message}) self._on_error(message) + def _on_meeting_warning(self, message): + """It was recorded and it is being written up, but read this first.""" + self.tray.showMessage("Dikte", message, + QSystemTrayIcon.MessageIcon.Warning, 12000) + def _on_meeting_died(self): if self.meeting_state != M_RECORDING: return diff --git a/i18n.py b/i18n.py index fad21fd..7ce2b3a 100644 --- a/i18n.py +++ b/i18n.py @@ -561,6 +561,24 @@ TR = { "Hangi ses çıkışının kaydedileceği anlaşılamadı. Ayarlar → Toplantı " "sekmesinden seç.", "Nothing was recorded: {error}": "Hiçbir şey kaydedilmedi: {error}", + "The saved macOS audio device uses an old numeric index. Open Settings and " + "select the device again before recording.": + "Kayıtlı macOS ses aygıtı eski bir sayısal indeks kullanıyor. Kayıttan " + "önce Ayarlar'ı açıp aygıtı yeniden seç.", + "The saved macOS audio device is no longer connected: {device}. Open " + "Settings and select another device.": + "Kayıtlı macOS ses aygıtı artık bağlı değil: {device}. Ayarlar'ı açıp " + "başka bir aygıt seç.", + "More than one macOS audio device is named {device}. Disconnect the " + "duplicate or choose a different device.": + "Birden fazla macOS ses aygıtının adı {device}. Aynı adlı aygıtlardan " + "birini çıkar ya da başka bir aygıt seç.", + "The microphone handed over almost nothing ({percent}% of the recording was " + "empty), so your own side of the meeting will be mostly missing. Check the " + "device before the next one.": + "Mikrofon neredeyse hiçbir şey iletmedi (kaydın %{percent} kadarı boştu), " + "toplantının senin tarafın büyük ölçüde eksik olacak. Bir sonrakinden " + "önce aygıtı kontrol et.", "Transcribing {side}: {index}/{count}…": "{side} yazıya çevriliyor: {index}/{count}…", "you": "sen", diff --git a/tests/test_audio.py b/tests/test_audio.py index 22d4f2a..5bda0ab 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -16,6 +16,7 @@ import json import os import subprocess import sys +import threading import unittest import wave from unittest import mock @@ -100,6 +101,22 @@ class StereoLevels(unittest.TestCase): self.assertAlmostEqual(left, 0.25, places=3) self.assertAlmostEqual(right, 0.5, places=3) + def test_two_mono_streams_are_interleaved_left_then_right(self): + self.assertEqual( + list(array.array("h", audio.interleave_mono( + pcm([100, 200, 300]), pcm([-100, -200, -300]) + ))), + [100, -100, 200, -200, 300, -300], + ) + + def test_interleaving_stops_at_the_shorter_stream(self): + self.assertEqual( + list(array.array("h", audio.interleave_mono( + pcm([100, 200]), pcm([-100]) + ))), + [100, -100], + ) + class WriteWav(DikteTest): def test_the_header_says_what_the_recorder_captured(self): @@ -235,6 +252,34 @@ class FakeProcess: self._alive = False +class StalledProcess(FakeProcess): + """A capture that hands over a buffer and then stops answering at all. + + Not the same thing as one that ends: the device is still there and the pipe + is still open, and a read of it never comes back. + """ + + def __init__(self, data): + super().__init__(data) + self.stdout = _StalledStream(data) + + +class _StalledStream: + def __init__(self, data): + self._data = io.BytesIO(data) + self._released = threading.Event() + + def read(self, size): + chunk = self._data.read(size) + if chunk: + return chunk + self._released.wait() + return b"" + + def release(self): + self._released.set() + + class RecordingCommand(OnLinux, DikteTest): """Which program captures the microphone, and how it is asked to.""" @@ -465,42 +510,178 @@ class RecorderChain(OnLinux, DikteTest): self.assertFalse(recorder.active) -class MeetingCommand(unittest.TestCase): - """One process reading both devices, because two would drift apart.""" +class MeetingCommands(unittest.TestCase): + """Pulse can share a process; AVFoundation sessions cannot.""" - def command(self, platform, mic="", system="them"): - with mock.patch.object(sys, "platform", platform): - return audio.meeting_command(mic, system) + def commands(self, platform, mic="", system="them"): + with mock.patch.object(sys, "platform", platform), \ + mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + mock.patch.object( + audio, "_resolve_avfoundation_target", + side_effect=lambda target, inputs=None: target or "default"): + return audio.meeting_commands(mic, system) def test_linux_reads_both_through_pulse(self): - cmd = self.command("linux", mic="mine") + commands = self.commands("linux", mic="mine") + self.assertEqual(len(commands), 1) + cmd = commands[0] self.assertEqual(cmd.count("pulse"), 2) self.assertEqual(cmd[cmd.index("mine") - 1], "-i") self.assertEqual(cmd[cmd.index("them") - 1], "-i") - def test_a_mac_reads_both_through_avfoundation(self): - cmd = self.command("darwin", mic="1") - self.assertEqual(cmd.count("avfoundation"), 2) - self.assertIn(":1", cmd) - self.assertIn(":them", cmd) + def test_a_mac_gives_each_avfoundation_device_its_own_process(self): + commands = self.commands("darwin", mic="mine") + self.assertEqual(len(commands), 2) + self.assertTrue(all(command.count("avfoundation") == 1 + for command in commands)) + self.assertIn(":mine", commands[0]) + self.assertIn(":them", commands[1]) def test_no_microphone_named_means_the_default_one(self): - self.assertIn("default", self.command("linux")) - self.assertIn(":default", self.command("darwin")) + self.assertIn("default", self.commands("linux")[0]) + self.assertIn(":default", self.commands("darwin")[0]) - def test_both_merge_the_two_into_one_stereo_stream(self): - for platform in ("linux", "darwin"): - with self.subTest(platform=platform): - cmd = self.command(platform) - self.assertIn(audio.MERGE_FILTER, cmd) - self.assertEqual(cmd[cmd.index("-map") + 1], "[out]") - self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le") + def test_pulse_merges_the_two_into_one_stereo_stream(self): + cmd = self.commands("linux")[0] + self.assertIn(audio.MERGE_FILTER, cmd) + self.assertEqual(cmd[cmd.index("-map") + 1], "[out]") + self.assertEqual(cmd[cmd.index("-f", cmd.index("-map")) + 1], "s16le") + + def test_each_mac_process_produces_clock_corrected_mono_pcm(self): + for cmd in self.commands("darwin"): + self.assertIn("first_pts=0", cmd[cmd.index("-af") + 1]) + self.assertEqual(cmd[cmd.index("-ac") + 1], "1") + self.assertEqual(cmd[-2:], ["1", "-"]) def test_neither_lets_ffmpeg_read_the_terminal(self): """It shares stdin with Dikte, and would eat a keypress meant for it.""" for platform in ("linux", "darwin"): with self.subTest(platform=platform): - self.assertIn("-nostdin", self.command(platform)) + for command in self.commands(platform): + self.assertIn("-nostdin", command) + + def test_both_mac_devices_are_read_off_one_listing(self): + """Asking twice costs an ffmpeg run, and the second answer could have + renumbered between the two.""" + with mock.patch.object(sys, "platform", "darwin"), \ + mock.patch.object(audio, "_avfoundation_inputs", + return_value=[("0", "mine"), + ("1", "them")]) as inputs: + audio.meeting_commands("mine", "them") + inputs.assert_called_once_with() + + +class MacMeetingRecorder(OnMacOS, DikteTest): + # Whichever way the machine has them ordered, a name is what is saved and + # the index it happens to hold now is what ffmpeg is given. + DEVICES = [("0", "External Headset"), ("1", "BlackHole 2ch"), + ("2", "MacBook Pro Microphone")] + + def devices(self): + return mock.patch.object(audio, "_avfoundation_inputs", + return_value=self.DEVICES) + + def record(self, mine, theirs): + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped, failed, warnings = [], [], [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + recorder.failed.connect(failed.append) + recorder.warned.connect(warnings.append) + processes = [FakeProcess(mine), FakeProcess(theirs)] + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(subprocess, "Popen", side_effect=processes) as popen: + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + recorder._thread.join(timeout=5) + recorder.stop() + return path, warnings, stopped, failed, processes, popen + + def test_the_two_capture_processes_become_one_stereo_file(self): + path, _, stopped, failed, _, _ = self.record( + tone(1.0, freq=440), tone(1.0, freq=880) + ) + self.assertEqual(failed, []) + self.assertEqual(len(stopped), 1) + with contextlib.closing(wave.open(path, "rb")) as wav: + self.assertEqual(wav.getnchannels(), 2) + self.assertEqual(wav.getframerate(), audio.RATE) + self.assertEqual(wav.getnframes(), audio.RATE) + + def test_each_avfoundation_device_is_opened_by_a_different_process(self): + _, _, _, _, _, popen = self.record(tone(0.5), tone(0.5)) + commands = [call.args[0] for call in popen.call_args_list] + self.assertEqual(len(commands), 2) + self.assertTrue(all(command.count("avfoundation") == 1 + for command in commands)) + self.assertIn(":2", commands[0]) + self.assertIn(":1", commands[1]) + + def test_a_mostly_empty_microphone_is_said_out_loud_and_still_kept(self): + """Half the file is everyone else, and an hour of them is worth more + than the empty channel costs.""" + path, warnings, stopped, failed, _, _ = self.record( + silence(11.0), tone(11.0) + ) + self.assertEqual(failed, []) + self.assertEqual(len(stopped), 1) + self.assertTrue(os.path.exists(path)) + self.assertIn("empty", warnings[0]) + + def test_a_microphone_that_was_merely_quiet_is_not_complained_about(self): + _, warnings, stopped, _, _, _ = self.record(tone(11.0), tone(11.0)) + self.assertEqual(warnings, []) + self.assertEqual(len(stopped), 1) + + def test_a_capture_that_falls_silent_ends_the_meeting_rather_than_hanging(self): + """One thread taking turns on both pipes would sit on the dead read + until somebody noticed, an hour later.""" + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped = [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + mine, theirs = StalledProcess(tone(0.512)), FakeProcess(tone(30.0)) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(audio, "STALL_SECONDS", 0.2), \ + mock.patch.object(subprocess, "Popen", side_effect=(mine, theirs)): + try: + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + recorder._thread.join(timeout=2) + self.assertFalse(recorder.active) + recorder.stop() + finally: + mine.stdout.release() + self.assertAlmostEqual(stopped[0][1], 0.512, places=3) + self.assertTrue(os.path.exists(path)) + + def test_stopping_ends_both_capture_processes(self): + _, _, _, _, processes, _ = self.record(tone(0.5), tone(0.5)) + self.assertTrue(all(process.signals for process in processes)) + + def test_a_legacy_numeric_target_fails_before_recording(self): + recorder = audio.MeetingRecorder() + failed = [] + recorder.failed.connect(failed.append) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(subprocess, "Popen") as popen: + recorder.start(str(self.path("meeting.wav")), "2", "1") + popen.assert_not_called() + self.assertIn("old numeric index", failed[0]) + + def test_a_second_capture_process_that_cannot_start_cleans_up_the_first(self): + """A Mac left holding an open AVFoundation session records nothing + else until it is let go.""" + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + failed = [] + recorder.failed.connect(failed.append) + first = FakeProcess(tone(1.0)) + with only_these_tools("ffmpeg"), self.devices(), \ + mock.patch.object(subprocess, "Popen", + side_effect=(first, OSError("refused"))): + recorder.start(path, "MacBook Pro Microphone", "BlackHole 2ch") + self.assertTrue(first.signals) + self.assertIn("refused", failed[0]) + self.assertFalse(os.path.exists(path)) class MacDevices(OnMacOS, DikteTest): @@ -527,12 +708,13 @@ class MacDevices(OnMacOS, DikteTest): def test_the_audio_half_of_the_listing_is_the_only_half_read(self): with self.listing(): self.assertEqual(audio.list_sources(), - [("0", "MacBook Pro Microphone"), ("1", "BlackHole 2ch")]) + [("MacBook Pro Microphone", "MacBook Pro Microphone"), + ("BlackHole 2ch", "BlackHole 2ch")]) - def test_the_index_is_what_ffmpeg_is_given_and_the_name_what_is_shown(self): + def test_the_name_is_both_saved_and_shown(self): with self.listing(): name, description = audio.list_sources()[1] - self.assertEqual(name, "1") + self.assertEqual(name, "BlackHole 2ch") self.assertIn("BlackHole", description) def test_no_ffmpeg_installed(self): @@ -557,7 +739,7 @@ class MacDevices(OnMacOS, DikteTest): def test_the_loopback_driver_is_picked_out_by_name(self): with self.listing(): - self.assertEqual(audio.default_monitor(), "1") + self.assertEqual(audio.default_monitor(), "BlackHole 2ch") def test_the_other_two_drivers_people_install(self): for name in ("Loopback Audio", "Soundflower (2ch)"): @@ -565,13 +747,43 @@ class MacDevices(OnMacOS, DikteTest): listing = ("AVFoundation audio devices:\n" f"[0] Built-in Microphone\n[1] {name}\n") with self.listing(stderr=listing): - self.assertEqual(audio.default_monitor(), "1") + self.assertEqual(audio.default_monitor(), name) def test_a_mac_with_nothing_to_record_the_far_side_from(self): listing = "AVFoundation audio devices:\n[0] MacBook Pro Microphone\n" with self.listing(stderr=listing): self.assertEqual(audio.default_monitor(), "") + def test_a_saved_name_is_resolved_against_the_current_index(self): + with self.listing(): + self.assertEqual(audio._resolve_avfoundation_target("BlackHole 2ch"), "1") + + def test_a_saved_name_follows_the_device_when_an_earlier_one_disappears(self): + listing = ("AVFoundation audio devices:\n" + "[0] BlackHole 2ch\n[1] MacBook Pro Microphone\n") + with self.listing(stderr=listing): + self.assertEqual( + audio._resolve_avfoundation_target("MacBook Pro Microphone"), "1" + ) + + def test_an_old_numeric_setting_is_not_silently_reused(self): + with self.assertRaises(audio.AudioDeviceError) as caught: + audio._resolve_avfoundation_target("1") + self.assertIn("old numeric index", str(caught.exception)) + + def test_a_device_that_went_away_is_said_out_loud(self): + with self.listing(), self.assertRaises(audio.AudioDeviceError) as caught: + audio._resolve_avfoundation_target("USB Microphone") + self.assertIn("no longer connected", str(caught.exception)) + + def test_duplicate_names_are_not_guessed_between(self): + listing = ("AVFoundation audio devices:\n" + "[0] USB Microphone\n[1] USB Microphone\n") + with self.listing(stderr=listing), \ + self.assertRaises(audio.AudioDeviceError) as caught: + audio._resolve_avfoundation_target("USB Microphone") + self.assertIn("More than one", str(caught.exception)) + class MacRecordingCommand(OnMacOS, DikteTest): def test_the_microphone_is_read_through_avfoundation(self): @@ -583,7 +795,11 @@ class MacRecordingCommand(OnMacOS, DikteTest): def test_the_empty_half_in_front_of_the_colon_is_the_missing_picture(self): with only_these_tools("ffmpeg"): self.assertIn(":default", audio.recording_command()) - self.assertIn(":2", audio.recording_command("2")) + listing = "AVFoundation audio devices:\n[2] USB Microphone\n" + completed = FakeCompleted(returncode=1, stderr=listing) + with only_these_tools("ffmpeg"), \ + mock.patch.object(subprocess, "run", return_value=completed): + self.assertIn(":2", audio.recording_command("USB Microphone")) def test_it_captures_the_format_the_rest_of_the_code_expects(self): with only_these_tools("ffmpeg"):