From 2cd561da77cb77064ea8320ed511d147dbadb6db Mon Sep 17 00:00:00 2001 From: firat Date: Sun, 9 Aug 2026 00:33:06 +0200 Subject: [PATCH 1/3] Harden macOS meeting audio capture --- audio.py | 267 ++++++++++++++++++++++++++++++++++---------- i18n.py | 16 +++ tests/test_audio.py | 194 +++++++++++++++++++++++++++----- 3 files changed, 393 insertions(+), 84 deletions(-) diff --git a/audio.py b/audio.py index 55beb50..bf15ff6 100644 --- a/audio.py +++ b/audio.py @@ -9,7 +9,8 @@ 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. +chooser picks between them. macOS uses one ffmpeg process per AVFoundation +device: two AVFoundation sessions in one process silently starve one another. """ import array @@ -63,7 +64,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,9 +190,24 @@ 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.""" - return sound().meeting(mic_target, system_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): @@ -208,11 +228,14 @@ class MeetingRecorder(QObject): 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 +257,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 +271,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 + )) + 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: @@ -260,6 +293,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 +302,75 @@ 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): + 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 - 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 +388,27 @@ 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 + 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 @@ -370,16 +446,30 @@ class MeetingRecorder(QObject): 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): - 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 +495,38 @@ def stereo_levels(chunk): 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 @@ -542,6 +664,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 @@ -551,15 +674,15 @@ def _avfoundation_record(target): ] -def _avfoundation_meeting(mic_target, system_target): +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":{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,10 +719,40 @@ 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): + """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 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 "" @@ -622,12 +775,12 @@ PULSE = Sound( COREAUDIO = Sound( record=_avfoundation_record, - meeting=_avfoundation_meeting, - inputs=_avfoundation_inputs, + 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_inputs, + outputs=_avfoundation_named_inputs, default_output=_avfoundation_default_output, missing="ffmpeg not found. Install it with: brew install ffmpeg", ) diff --git a/i18n.py b/i18n.py index 436f917..52ab91c 100644 --- a/i18n.py +++ b/i18n.py @@ -554,6 +554,22 @@ 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 macOS microphone stopped delivering audio ({percent}% was empty). The " + "unusable recording was discarded; reconnect the device and try again.": + "macOS mikrofonu ses iletmeyi durdurdu (kaydın %{percent} kadarı boştu). " + "Kullanılamaz kayıt silindi; aygıtı yeniden bağlayıp tekrar dene.", "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..7a6a09e 100644 --- a/tests/test_audio.py +++ b/tests/test_audio.py @@ -100,6 +100,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): @@ -465,42 +481,131 @@ 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, "_resolve_avfoundation_target", + side_effect=lambda target: 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) + + +class MacMeetingRecorder(OnMacOS, DikteTest): + def record(self, mine, theirs): + path = str(self.path("meeting.wav")) + recorder = audio.MeetingRecorder() + stopped, failed = [], [] + recorder.stopped.connect(lambda *args: stopped.append(args)) + recorder.failed.connect(failed.append) + processes = [FakeProcess(mine), FakeProcess(theirs)] + with only_these_tools("ffmpeg"), \ + mock.patch.object(audio, "_resolve_avfoundation_target", + side_effect=("2", "1")), \ + 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, recorder, 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_an_unusable_mostly_empty_microphone_is_not_transcribed(self): + path, _, stopped, failed, _, _ = self.record( + silence(11.0), tone(11.0) + ) + self.assertEqual(stopped, []) + self.assertEqual(len(failed), 1) + self.assertIn("empty", failed[0]) + self.assertFalse(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"), \ + mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + 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): + 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"), \ + mock.patch.object(audio, "_resolve_avfoundation_target", + side_effect=("2", "1")), \ + 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 +632,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 +663,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 +671,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 +719,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"): From 44cfb76652ffa94685523bab0b801687e2d70d9a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Can=20Soykan=20Y=C4=B1lmaz?= Date: Sat, 15 Aug 2026 15:56:27 +0300 Subject: [PATCH 2/3] Give a Mac an installer, an app bundle and a menu bar icon it can see The macOS backends were already here: CoreAudio capture through ffmpeg, pbcopy and CoreGraphics, Carbon hotkeys, the paths under ~/Library. What was missing was everything that installs them, so install.sh hands over to install-mac.sh on Darwin rather than growing a branch per line: the XDG directories, the .desktop files and the shortcut registry mean nothing there, and an application is a bundle rather than a path. The bundle carries a copy of the interpreter, because macOS files the microphone and Accessibility permissions against the process that asks, and a launcher running Homebrew's python3 would have asked as python3 and shared the grant with everything else on that interpreter. It is signed ad-hoc so a reinstall is the same application rather than two more dialogs, and it says so plainly when a brew upgrade has moved the tree it needs. uninstall.sh and update.sh follow it. The tray icon was invisible: QIcon.fromTheme wants a freedesktop icon theme and hands back a null icon without one, which in a menu bar is the whole interface gone. trayicon.py draws the three shapes as template images, so they follow the menu bar into dark mode, and the bundle's icon comes off the same glyph rather than a binary in the repository. Linux keeps its own icons; these are used only where the theme has nothing. paths.py is the fix that was never about a Mac. config.py imports ggml.py, so ggml.py could not ask it where the data goes; each worked it out for itself and only one of them knew about macOS. Settings went to ~/Library while several gigabytes of models went to ~/.local/share, which is not a place a Mac user looks and not a place uninstall.sh --purge would have deleted from. Ctrl+Space is the input-source switch there and Cmd+Space is Spotlight, so the default is Ctrl+Option+Space, and hotkey.default_combo is the one place that difference lives. The first paste asks for Accessibility with kAXTrustedCheckOptionPrompt, which is what creates the row to switch on; asking the other way opens a pane Dikte is not listed in. `dikte shortcut status` asks the running instance, since the combination is held by that process and by nothing else. Local speech to text is the one piece a Mac builds by hand. whisper.cpp publishes no macOS binary and Homebrew's is configured with WHISPER_BUILD_SERVER=OFF, so it installs whisper-cli and not the server Dikte talks to; program_path already takes a whisper-server off the PATH or out of Settings, so the answer is the one a Linux distribution gets, and the README carries the cmake line. CI grows a macOS job on 3.11 and 3.13, the only place the Carbon and CoreGraphics libraries have to be there to be opened. Written and tested on macOS 27.0 arm64. Two things are still unverified on a Mac: the paste end to end, which waits on the Accessibility toggle, and a meeting recording, which needs a loopback driver. --- .github/workflows/tests.yml | 38 ++++- README.md | 36 ++++- README.tr.md | 35 +++- cli.py | 22 ++- config.py | 34 ++-- dikte.py | 60 +++++++ ggml.py | 18 ++- hotkey.py | 17 ++ i18n.py | 7 + install-mac.sh | 312 ++++++++++++++++++++++++++++++++++++ install.sh | 9 ++ paste.py | 60 ++++++- paths.py | 36 +++++ settings_ui.py | 13 +- tests/test_cli.py | 51 ++++++ tests/test_config.py | 28 ---- tests/test_ggml.py | 2 +- tests/test_hotkey.py | 10 ++ tests/test_paste.py | 36 +++++ tests/test_paths.py | 62 +++++++ tests/test_ui.py | 10 +- trayicon.py | 213 ++++++++++++++++++++++++ uninstall.sh | 92 ++++++++--- update.sh | 17 +- 24 files changed, 1116 insertions(+), 102 deletions(-) create mode 100755 install-mac.sh create mode 100644 paths.py create mode 100644 tests/test_paths.py create mode 100644 trayicon.py diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index e8f0798..86eaa88 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -6,7 +6,7 @@ on: pull_request: jobs: - test: + linux: runs-on: ubuntu-latest strategy: fail-fast: false @@ -38,3 +38,39 @@ jobs: # tests reach neither the network nor a sound device. - name: Run the tests run: python -m unittest discover --verbose + + # The macOS backends are written to be checked from either side, so most of + # what runs here also runs above. What only this job can catch is the half + # that reads the real platform: the Carbon and CoreGraphics libraries have to + # be there to be opened, the paths under ~/Library have to be the ones macOS + # actually uses, and the Linux tests are skipped rather than failed. + macos: + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + python: ["3.11", "3.13"] + + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: ${{ matrix.python }} + + # No apt step: PyQt6's wheel carries the frameworks it needs on a Mac. + - name: Install PyQt6 + run: python -m pip install --quiet PyQt6 + + - name: Run the tests + run: python -m unittest discover --verbose + + # The installer is most of what is new on a Mac, and a syntax error in it + # would otherwise only turn up on somebody's machine. Running it is not on + # the table here: it wants a microphone, a login session and a keychain. + - name: Check the installer parses + run: | + bash -n install.sh + bash -n install-mac.sh + bash -n update.sh + bash -n uninstall.sh diff --git a/README.md b/README.md index 5517d69..e408c94 100644 --- a/README.md +++ b/README.md @@ -55,11 +55,32 @@ tools instead: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -macOS has no shortcut registry for `install.sh` to install into, so the listener -that catches the keys is the mechanism there and there is nothing to run: -`brew install ffmpeg`, `pip install PyQt6`, then `python dikte.py`. A meeting -needs BlackHole or Loopback, because nothing else offers what the speakers are -playing. +On macOS the same `./install.sh` runs and hands over to `install-mac.sh`, which +puts down a `Dikte.app` in `~/Applications`, the `dikte` command and a +LaunchAgent: + +```sh +brew install pyqt ffmpeg # pyqt brings a Python newer than Apple's 3.9 with it + +./install.sh # or: ./install.sh "Ctrl+Option+Space" "Ctrl+Option+D" +open -a Dikte +``` + +The bundle is what macOS files the **Microphone** and **Accessibility** +permissions against, and it asks for each the first time it needs one. The +default there is `Ctrl+Option+Space`, since macOS keeps `Ctrl+Space` for the +input-source switch, and nothing needs a logout: Dikte holds the combination +itself while it runs. PyQt6 comes from brew rather than pip because Homebrew's +Python refuses to be installed into, and `DIKTE_PYTHON=…/venv/bin/python +./install.sh` points the installer at a virtualenv instead. + +Local speech to text is the one piece that has to be built by hand there: +whisper.cpp publishes no macOS binary and Homebrew's is configured with +`WHISPER_BUILD_SERVER=OFF`, so it installs `whisper-cli` and not the server +Dikte talks to. Build it (`cmake -B build -DWHISPER_BUILD_SERVER=ON +-DGGML_METAL=ON && cmake --build build -j`) and give Settings → API the path, or +transcribe in the cloud. A meeting needs BlackHole or Loopback +(`brew install blackhole-2ch`); dictation does not. `install.sh` adds the `dikte` command, a menu entry, an autostart entry and the two global shortcuts, whose keys are its two arguments. `./update.sh` pulls and @@ -189,8 +210,9 @@ vad.py deciding whether a recording holds speech at all filetranscribe.py file transcription: ffmpeg, chunking, timestamps overlay.py the corner indicator settings_ui.py settings window -hotkey.py KDE shortcut installation and the evdev listener -paste.py wl-clipboard and ydotool wrappers +hotkey.py KDE shortcut installation, the evdev listener, Carbon on a Mac +paste.py wl-clipboard and ydotool wrappers, pbcopy and CoreGraphics +trayicon.py the tray icons, drawn where there is no icon theme i18n.py the string table ``` diff --git a/README.tr.md b/README.tr.md index edebd17..db1f23c 100644 --- a/README.tr.md +++ b/README.tr.md @@ -54,10 +54,32 @@ araçlarıyla çalışır: sudo apt install pulseaudio-utils xclip xdotool ffmpeg ``` -macOS'ta `install.sh`'ın kuracağı bir kısayol kaydı yok, tuşları yakalayan -dinleyici orada mekanizmanın kendisi, dolayısıyla kurulacak bir şey de yok: -`brew install ffmpeg`, `pip install PyQt6`, sonra `python dikte.py`. Toplantı -için BlackHole ya da Loopback gerekiyor, hoparlörden çıkanı kimse vermiyor. +macOS'ta da aynı `./install.sh` çalışır, işi `install-mac.sh`'a devreder; +`~/Applications` içine bir `Dikte.app`, `dikte` komutunu ve bir LaunchAgent +kurar: + +```sh +brew install pyqt ffmpeg # pyqt Apple'ın 3.9'undan yeni bir Python'ı da getirir + +./install.sh # ya da: ./install.sh "Ctrl+Option+Space" "Ctrl+Option+D" +open -a Dikte +``` + +macOS **Mikrofon** ve **Erişilebilirlik** izinlerini uygulama paketinin kimliğine +yazıyor, ikisini de ilk gerektiğinde soruyor. Varsayılan kısayol orada +`Ctrl+Option+Space`, çünkü `Ctrl+Space` giriş kaynağını değiştirmeye ayrılmış; ve +hiçbir şey için oturumu kapatman gerekmez, kombinasyonu Dikte çalışırken kendisi +tutar. PyQt6 pip'ten değil brew'dan geliyor, Homebrew'un Python'u içine +kurulmayı reddediyor; sanal ortam kullanacaksan +`DIKTE_PYTHON=…/venv/bin/python ./install.sh`. + +Orada elle derlenmesi gereken tek parça yerel transkripsiyon: whisper.cpp'nin +macOS sürümü yok, Homebrew'unki de `WHISPER_BUILD_SERVER=OFF` ile derleniyor, +yani `whisper-cli` kuruluyor, Dikte'nin konuştuğu sunucu değil. Kendin derle +(`cmake -B build -DWHISPER_BUILD_SERVER=ON -DGGML_METAL=ON && cmake --build +build -j`) ve yolunu Ayarlar → API'ye yaz, ya da buluta çevir. Toplantı için +BlackHole veya Loopback gerekiyor (`brew install blackhole-2ch`); dikte için +gerekmiyor. `install.sh` `dikte` komutunu, menü girdisini, oturum açılışında otomatik başlatmayı ve iki global kısayolu kurar; tuşları da iki argümanı. `./update.sh` @@ -186,8 +208,9 @@ vad.py kayıtta gerçekten konuşma var mı kararı filetranscribe.py dosyadan transkript: ffmpeg, parçalama, zaman damgaları overlay.py köşedeki gösterge settings_ui.py ayarlar penceresi -hotkey.py KDE kısayol kurulumu ve evdev dinleyici -paste.py wl-clipboard ve ydotool sarmalayıcıları +hotkey.py KDE kısayol kurulumu, evdev dinleyici, Mac'te Carbon +paste.py wl-clipboard ve ydotool sarmalayıcıları, pbcopy ve CoreGraphics +trayicon.py tepsi simgeleri, ikon teması olmayan yerler için çizilmiş i18n.py metin tablosu ``` diff --git a/cli.py b/cli.py index 7992bd0..e122905 100644 --- a/cli.py +++ b/cli.py @@ -697,16 +697,27 @@ def cmd_test_key(opts): def cmd_shortcut(opts): conf = cfg.Config() if opts.shortcut == "status": + # The running instance is asked first. On KDE and GNOME this process + # could read the registry itself and get the same answer; on macOS + # there is no registry, the combination is held by that process and by + # nothing else, and asking here would report every shortcut as missing + # while they all work. + live = ipc.send("status") or {} + registered = live.get("shortcuts") or {} rows = {} for name, spec in hotkey.SHORTCUTS.items(): - rows[name] = {"registered": hotkey.shortcut_status(spec.desktop_id), - "configured": conf[spec.setting]} + rows[name] = { + "registered": registered.get( + name, hotkey.shortcut_status(spec.desktop_id)), + "configured": conf[spec.setting], + } + listener = live.get("listener", conf["evdev_hotkey"]) lines = [f"{name:8} {row['registered'] or '(not installed)':16} " f"setting: {row['configured'] or '(none)'}" for name, row in rows.items()] - lines.append(f"built-in listener: {'on' if conf['evdev_hotkey'] else 'off'}") + lines.append(f"built-in listener: {'on' if listener else 'off'}") return out(opts, {"ok": True, "shortcuts": rows, - "listener": conf["evdev_hotkey"]}, "\n".join(lines)) + "listener": listener}, "\n".join(lines)) spec = hotkey.SHORTCUTS[opts.which] if opts.shortcut == "remove": @@ -714,7 +725,8 @@ def cmd_shortcut(opts): return out(opts, {"ok": True, "removed": opts.which}, f"Removed the {opts.which} shortcut.") - combo = (opts.combo or conf[spec.setting] or spec.fallback).strip() + combo = (opts.combo or conf[spec.setting] + or hotkey.default_combo(opts.which)).strip() if not combo: return fail(opts, "no combination given and none stored; pass --combo", 2) if not hotkey.valid_shortcut(combo): diff --git a/config.py b/config.py index c80bbfb..4dd4088 100644 --- a/config.py +++ b/config.py @@ -4,34 +4,21 @@ import collections import hashlib import json import os -import pathlib import sys import api import ggml import i18n import paste +import paths from i18n import t -def _xdg(var, default): - return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) +_MACOS = sys.platform == "darwin" - -def _directories(platform=None): - """(settings, data), in the two places this system keeps them. - - macOS keeps both in the one directory a Mac user's backup already knows - about. Everywhere else they are separate and follow the XDG variables. - """ - if (platform or sys.platform) == "darwin": - support = pathlib.Path.home() / "Library/Application Support/Dikte" - return support, support - return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", - _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") - - -CONFIG_DIR, DATA_DIR = _directories() +# In paths.py rather than here, because ggml.py needs the same answer and +# cannot ask this module: the import already runs the other way. +CONFIG_DIR, DATA_DIR = paths.CONFIG_DIR, paths.DATA_DIR CONFIG_FILE = CONFIG_DIR / "config.json" HISTORY_FILE = DATA_DIR / "history.jsonl" RECORDINGS_DIR = DATA_DIR / "recordings" @@ -438,11 +425,16 @@ DEFAULTS = { "speech_margin_db": 10.0, # how far speech must rise above the noise floor "min_voiced_seconds": 0.3, "filter_hallucinations": True, - "shortcut": "Ctrl+Space", + # Ctrl+Space everywhere except a Mac, where macOS itself holds it for the + # input-source switch and Cmd+Space for Spotlight: neither is ours to take, + # so there Dikte starts on a combination a stock system leaves free. + "shortcut": "Ctrl+Option+Space" if _MACOS else "Ctrl+Space", # Ctrl+Alt+Space rather than Escape: the combination the recording started # with, one modifier along. Escape belongs to whatever window has focus, and - # while you are dictating something else usually has it. - "cancel_shortcut": "Ctrl+Alt+Space", + # while you are dictating something else usually has it. On a Mac that same + # trick lands on the toggle, Alt and Option being one key, so discarding + # gets a letter instead. + "cancel_shortcut": "Ctrl+Option+D" if _MACOS else "Ctrl+Alt+Space", "evdev_hotkey": False, "overlay_corner": "bottom-left", "keep_audio": False, diff --git a/dikte.py b/dikte.py index cda7f54..a0e8718 100755 --- a/dikte.py +++ b/dikte.py @@ -8,6 +8,8 @@ command line says "there is no instance to talk to, so be one". """ import contextlib +import ctypes +import ctypes.util import json import os import signal @@ -43,6 +45,7 @@ import hotkey # noqa: E402 import i18n # noqa: E402 import ipc # noqa: E402 import meeting # noqa: E402 +import trayicon # noqa: E402 from i18n import t # noqa: E402 from meeting import MeetingPipeline # noqa: E402 from overlay import Overlay # noqa: E402 @@ -218,7 +221,12 @@ class Dikte: self._toggle() def _set_icon(self, name): + # The theme first, so a Linux desktop keeps its own icons, then the ones + # drawn in trayicon.py. macOS has no theme at all and would otherwise be + # handed a null icon, which in a menu bar is an item you cannot see. icon = QIcon.fromTheme(name) + if icon.isNull(): + icon = trayicon.icon(name) if icon.isNull(): icon = QIcon.fromTheme("audio-input-microphone") self.tray.setIcon(icon) @@ -473,6 +481,12 @@ class Dikte: "agent": assistant.display_name(self.conf), "provider": assistant.provider(self.conf), "listener": self.evdev.running, + # Asked here rather than by the command line, because on macOS + # there is no registry to read: a combination is held by this + # process and by nothing else, so this is the only process that + # can say whether it is. + "shortcuts": {name: hotkey.shortcut_status(spec.desktop_id) + for name, spec in hotkey.SHORTCUTS.items()}, } def reload_settings(self): @@ -958,6 +972,51 @@ def install_signal_handlers(app): return reader, writer, notifier +def _stay_out_of_the_dock(): + """Ask macOS to treat this as a menu bar application, not a windowed one. + + LSUIElement in the bundle says the same thing, but it is read for the + process LaunchServices started, and that is the launcher script rather than + the Python it runs: the interpreter is a child, and the child inherits the + registration without inheriting the policy. Said here it holds however Dikte + was started, including straight from a terminal. + + Accessory rather than Prohibited: a prohibited application cannot put + anything in the menu bar, which is the whole interface. + """ + if sys.platform != "darwin": + return + NS_ACCESSORY = 1 # NSApplicationActivationPolicyAccessory + try: + objc = ctypes.cdll.LoadLibrary(ctypes.util.find_library("objc")) + objc.objc_getClass.restype = ctypes.c_void_p + objc.objc_getClass.argtypes = [ctypes.c_char_p] + objc.sel_registerName.restype = ctypes.c_void_p + objc.sel_registerName.argtypes = [ctypes.c_char_p] + # objc_msgSend is a trampoline with no signature of its own, and on + # arm64 the arguments have to be in the registers the real method + # expects, so each call gets a prototype of its own. Built from the + # address: handing CFUNCTYPE the imported function object would make a + # callback into it rather than a call through it, and the crash lands + # inside the Objective-C runtime with nothing to read. + send = ctypes.cast(objc.objc_msgSend, ctypes.c_void_p).value + shared = ctypes.CFUNCTYPE( + ctypes.c_void_p, ctypes.c_void_p, ctypes.c_void_p, + )(send) + policy = ctypes.CFUNCTYPE( + ctypes.c_bool, ctypes.c_void_p, ctypes.c_void_p, ctypes.c_long, + )(send) + + application = shared(objc.objc_getClass(b"NSApplication"), + objc.sel_registerName(b"sharedApplication")) + if application: + policy(application, objc.sel_registerName(b"setActivationPolicy:"), + NS_ACCESSORY) + except (OSError, AttributeError, TypeError): + # A Dock icon is a blemish, not a failure: everything still works. + pass + + def run_app(args): command = args[0] if args else "" @@ -965,6 +1024,7 @@ def run_app(args): app.setApplicationName("Dikte") app.setDesktopFileName("dikte") app.setQuitOnLastWindowClosed(False) + _stay_out_of_the_dock() # Before Dikte is built, because building it is what may start a server, and # a signal arriving in the middle of that would otherwise take the default # action and leave the server behind. A signal this early lands in the diff --git a/ggml.py b/ggml.py index 5ba85a7..3278f46 100644 --- a/ggml.py +++ b/ggml.py @@ -45,14 +45,17 @@ import urllib.error import urllib.request import hub +import paths from i18n import t HOST = "127.0.0.1" # The path api.py asks for, so its URL and the server's line up. INFERENCE_PATH = "/v1/audio/transcriptions" -DATA_DIR = (pathlib.Path(os.environ.get("XDG_DATA_HOME") - or os.path.expanduser("~/.local/share")) / "dikte") +# Not worked out here: a Mac keeps its data under ~/Library, and a copy of the +# rule that did not know that put several gigabytes of models somewhere no Mac +# user looks and uninstall.sh never deleted from. +DATA_DIR = paths.DATA_DIR BIN_DIR = DATA_DIR / "bin" MODELS_DIR = DATA_DIR / "models" @@ -319,10 +322,17 @@ def install_program(program, tag="", on_progress=None, should_stop=None, if item: break if item is None: + # Nothing to download and nothing to install for you: whisper.cpp + # publishes no macOS binary, and Homebrew's whisper-cpp is configured + # with WHISPER_BUILD_SERVER=OFF, so it is whisper-cli that lands and not + # the server Dikte talks to. Building it is a cmake line, and the + # binary is picked up from the PATH or from the box above, the same way + # a distribution's own build is on Linux. if sys.platform == "darwin" and program is WHISPER: raise LocalError(t( - "whisper.cpp publishes no macOS build. Install it with: " - "brew install whisper-cpp" + "whisper.cpp has no macOS build, and Homebrew's leaves out the " + "server. Build whisper-server yourself and give its path here, " + "or transcribe in the cloud. See the README." )) raise LocalError(t("{repo} {tag} has no build for this machine.", repo=program.repo, tag=tag)) diff --git a/hotkey.py b/hotkey.py index e7e10ea..0cbcfc4 100644 --- a/hotkey.py +++ b/hotkey.py @@ -56,6 +56,11 @@ SHORTCUTS = { "meeting_shortcut", ""), } +# The fallbacks above are Linux's. macOS holds Ctrl+Space for the input-source +# switch, so asking for it there gets a combination that either loses to the +# system or fires while the keyboard layout changes underneath the dictation. +MACOS_FALLBACKS = {"toggle": "Ctrl+Option+Space"} + # --- evdev key codes (linux/input-event-codes.h) -------------------------- EV_KEY = 0x01 @@ -551,6 +556,18 @@ def listener(parent=None): return CarbonHotkey(parent) if _macos() else EvdevHotkey(parent) +def default_combo(which): + """What to register for `which` when the setting has been cleared. + + The one place the platforms disagree about a default, so that the command + line and the settings window cannot drift apart on it. + """ + if _macos(): + return MACOS_FALLBACKS.get(which, "") + spec = SHORTCUTS.get(which) + return spec.fallback if spec else "" + + def valid_shortcut(text): """Whether this machine can bind the combination as it was typed.""" parse = parse_macos_shortcut if _macos() else parse_shortcut diff --git a/i18n.py b/i18n.py index 436f917..fad21fd 100644 --- a/i18n.py +++ b/i18n.py @@ -133,6 +133,13 @@ TR = { "Could not connect: {reason}": "Bağlantı kurulamadı: {reason}", "Could not parse the response: {error}": "Yanıt çözümlenemedi: {error}", + "whisper.cpp has no macOS build, and Homebrew's leaves out the server. " + "Build whisper-server yourself and give its path here, or transcribe in " + "the cloud. See the README.": + "whisper.cpp'nin macOS sürümü yok, Homebrew'unki de sunucuyu dışarıda " + "bırakıyor. whisper-server'ı kendin derleyip yolunu buraya yaz, ya da " + "buluta çevir. README'ye bak.", + # --- settings: tabs and general ------------------------------------ "Dikte Settings": "Dikte Ayarları", "General": "Genel", diff --git a/install-mac.sh b/install-mac.sh new file mode 100755 index 0000000..34c3f7a --- /dev/null +++ b/install-mac.sh @@ -0,0 +1,312 @@ +#!/usr/bin/env bash +# Dikte on macOS: dependency check, the application bundle, the command, and +# the login item. install.sh hands over to this on a Mac; run it directly and +# it does the same thing. +# +# A Mac needs a bundle where Linux needs a .desktop file, and for the same +# reason plus one: it is what puts a name and an icon in the Finder, and it is +# also the identity macOS files the microphone and Accessibility permissions +# under. Run as a bare script instead, every permission is granted to whatever +# copy of python3 happened to run it, and it is granted again the next time +# Homebrew moves that copy. +set -euo pipefail + +DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +APP_DIR="$HOME/Applications" +APP="$APP_DIR/Dikte.app" +BIN_DIR="$HOME/.local/bin" +AGENT_DIR="$HOME/Library/LaunchAgents" +AGENT_ID="io.github.yusufipk.dikte" +AGENT="$AGENT_DIR/$AGENT_ID.plist" +SHORTCUT="${1:-Ctrl+Option+Space}" +# Without the colon, so that a second argument given as "" stays empty. That is +# how update.sh says "this one was turned off", as against not saying anything. +CANCEL_SHORTCUT="${2-Ctrl+Option+D}" + +# The two places Homebrew installs to, in front, for the same reason dikte.py +# puts them there: a shell that has not been logged into since Homebrew was +# installed does not have them, and this script would then report ffmpeg as +# missing while the application finds it perfectly well. +PATH="/opt/homebrew/bin:/usr/local/bin:$PATH" + +say() { printf ' %s\n' "$1"; } +ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } +warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } +die() { printf ' \033[31m✗\033[0m %s\n' "$1"; echo; exit 1; } + +echo +echo "Installing Dikte" +echo "────────────────" + +# 1. An interpreter new enough --------------------------------------------- +# The system python3 is 3.9 and will stay 3.9: Apple ships it for its own +# scripts, not for anybody's application, and Dikte needs 3.11. So the first +# question is not "is python3 there" but "which python3", and the answer is +# written into the bundle rather than looked up again at launch, because a +# bundle started from the Finder gets none of the shell's PATH. +find_python() { + local candidate + for candidate in \ + "${DIKTE_PYTHON:-}" \ + /opt/homebrew/bin/python3.14 /opt/homebrew/bin/python3.13 \ + /opt/homebrew/bin/python3.12 /opt/homebrew/bin/python3.11 \ + /usr/local/bin/python3.14 /usr/local/bin/python3.13 \ + /usr/local/bin/python3.12 /usr/local/bin/python3.11 \ + python3.14 python3.13 python3.12 python3.11 python3 + do + [[ -n "$candidate" ]] || continue + candidate="$(command -v "$candidate" 2>/dev/null)" || continue + # Resolved, so that the bundle does not point at a Homebrew shim that a + # later `brew upgrade` repoints at a version Dikte cannot run on. + candidate="$(cd "$(dirname "$candidate")" && pwd)/$(basename "$candidate")" + # Exit status, not printed output: a candidate that cannot run Python at + # all and one that is too old are the same answer here. + if "$candidate" -c 'import sys; raise SystemExit(sys.version_info < (3, 11))' 2>/dev/null; then + printf '%s' "$candidate" + return 0 + fi + done + return 1 +} + +PY="$(find_python || true)" +if [[ -z "$PY" ]]; then + warn "No Python 3.11 or newer found (the one Apple ships is 3.9)." + say "brew install python@3.13" + die "Nothing was installed." +fi +ok "Python: $PY ($("$PY" -c 'import platform; print(platform.python_version())'))" + +# 2. The rest of the dependencies ------------------------------------------ +missing=() +"$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null || missing+=("PyQt6") +command -v ffmpeg >/dev/null || missing+=("ffmpeg") + +if ((${#missing[@]})); then + warn "Missing: ${missing[*]}" + for item in "${missing[@]}"; do + case "$item" in + # Not pip: Homebrew's python is marked externally managed, so installing + # into it is refused. `brew install pyqt` puts PyQt6 where its own + # interpreter already looks. A virtualenv is the other answer, and then + # DIKTE_PYTHON is how this script is pointed at it. + PyQt6) say "PyQt6: brew install pyqt" ;; + ffmpeg) say "ffmpeg: brew install ffmpeg" ;; + esac + done + # ffmpeg alone is survivable: it is what records, so nothing works without + # it, but the settings window opens and says so. PyQt6 is not: there is no + # window to say anything in. + if [[ " ${missing[*]} " == *" PyQt6 "* ]]; then + echo + die "PyQt6 is what the whole interface is; install it and run this again." + fi + echo +else + ok "All dependencies present" +fi + +# 3. The application bundle ------------------------------------------------- +mkdir -p "$APP/Contents/MacOS" "$APP/Contents/Resources" + +# The interpreter is copied in rather than run where it lies, and that copy is +# what the whole of macOS's idea of "which application is this" rests on. +# +# A bundle whose launcher runs an interpreter somewhere else is not that +# interpreter's application: the process is /opt/homebrew/…/python3.13, so that +# is the name in the microphone dialog, that is the row in Accessibility, and +# every application on the machine sharing that interpreter shares the +# permission. Copied to Contents/MacOS and exec'd from there, the running +# executable sits inside Dikte.app, macOS reads the Info.plist above it, and +# the permissions are Dikte's: its name, its icon, its row. +# +# The copy runs because the framework it links against is named by an absolute +# path; what it loses is the tree it was found in, which is what PYTHONHOME and +# PYTHONPATH below hand back. Both of those are what a `brew upgrade python` +# moves out from under it, so the launcher checks before it starts: a bundle +# that fails silently is a menu bar with nothing in it and no way to guess why. +PY_REAL="$("$PY" -c 'import os, sys; print(os.path.realpath(getattr(sys, "_base_executable", sys.executable)))')" +PY_HOME="$("$PY" -c 'import sys; print(sys.base_prefix)')" +PY_SITE="$("$PY" -c 'import site; print(site.getsitepackages()[0])')" +cp -f "$PY_REAL" "$APP/Contents/MacOS/python3" + +# exec, and this time it is right. The earlier version of this script did not +# exec, because exec'ing an interpreter outside the bundle throws away the +# application registration LaunchServices handed to the process it started, and +# what is left answers the socket while drawing no menu bar icon at all. The +# target here is inside the bundle, so the registration survives it. +cat > "$APP/Contents/MacOS/Dikte" </dev/null 2>&1 + exit 1 +fi +exec "\$HERE/python3" "$DIR/dikte.py" --gui "\$@" +EOF +chmod +x "$APP/Contents/MacOS/Dikte" + +# LSUIElement is the line that makes this a menu bar application: no Dock icon, +# no menu bar of its own, nothing in the app switcher. The usage strings are +# not decoration either, they are what the permission dialog reads out, and a +# bundle that asks for the microphone without one is killed rather than asked +# about. +version="$(cd "$DIR" && git describe --tags --always 2>/dev/null || echo 0)" +cat > "$APP/Contents/Info.plist" < + + + + CFBundleName Dikte + CFBundleDisplayName Dikte + CFBundleIdentifier $AGENT_ID + CFBundleExecutable Dikte + CFBundleIconFile Dikte + CFBundlePackageType APPL + CFBundleShortVersionString$version + CFBundleVersion $version + LSMinimumSystemVersion 11.0 + NSHighResolutionCapable + LSUIElement + NSMicrophoneUsageDescription + Dikte records what you dictate so that it can be transcribed. + NSAppleEventsUsageDescription + Dikte puts the transcript on the clipboard and pastes it into the window you were typing in. + + +EOF +printf 'APPL????' > "$APP/Contents/PkgInfo" + +# The icon, drawn by trayicon.py so that there is no binary in the repository +# and no second place to change what Dikte looks like. Failing to draw it is +# not worth stopping for: a bundle with no icon gets the generic one. +iconset="$(mktemp -d)/Dikte.iconset" +if "$PY" "$DIR/trayicon.py" "$iconset" >/dev/null 2>&1 \ + && iconutil -c icns "$iconset" -o "$APP/Contents/Resources/Dikte.icns" 2>/dev/null; then + ok "Icon drawn" +else + warn "Could not build the icon; the bundle gets the generic one" +fi +rm -rf "$(dirname "$iconset")" + +# Signed, ad-hoc, and this is the load-bearing step. macOS remembers a +# permission against a code signature; with none, it remembers a path and a +# hash of the bundle, and every reinstall is a bundle it has never seen, which +# means granting Accessibility and the microphone again each time. --force +# because a reinstall is signing over the last signature. +if codesign --force --sign - --identifier "$AGENT_ID" "$APP" 2>/dev/null; then + ok "Application bundle: $APP" +else + warn "Could not sign $APP; macOS will ask for its permissions again on every" + say "reinstall. Install the command line tools: xcode-select --install" +fi + +# LaunchServices does not necessarily notice a bundle written under a directory +# it was not watching, and until it does, `open -a Dikte` cannot find it. +/System/Library/Frameworks/CoreServices.framework/Frameworks/LaunchServices.framework/Support/lsregister \ + -f "$APP" >/dev/null 2>&1 || true + +# 4. The command ------------------------------------------------------------ +# A wrapper, where Linux gets a symlink to dikte.py. The shebang there is +# `env python3`, and on a Mac that is Apple's 3.9: the symlink would resolve to +# the one interpreter that cannot run this. Naming the interpreter here also +# gives update.sh and uninstall.sh somewhere to read it from, so that the three +# scripts cannot disagree about which Python this installation is on. +mkdir -p "$BIN_DIR" +cat > "$BIN_DIR/dikte" < "$AGENT" < + + + + Label $AGENT_ID + ProgramArguments + + /usr/bin/open + -a + $APP + + RunAtLoad + KeepAlive + ProcessType Interactive + + +EOF +# Through `open` rather than running the launcher directly, so that the process +# is one LaunchServices started: that is what gives it the bundle's identity, +# and so the permissions granted to Dikte rather than to launchd. +launchctl bootout "gui/$(id -u)/$AGENT_ID" >/dev/null 2>&1 || true +if launchctl bootstrap "gui/$(id -u)" "$AGENT" >/dev/null 2>&1; then + ok "Will start automatically on login" +else + warn "Could not register the login item; add $APP under" + say "System Settings → General → Login Items instead." +fi + +# 6. The shortcuts ---------------------------------------------------------- +# Dikte registers them rather than this script writing a file: macOS keeps no +# shortcut registry at all, so "installed" means the running application is +# holding the combination, and the settings file is where it reads it from +# after a restart. Nothing here needs a logout, unlike KDE. +if [[ "$SHORTCUT" == "$CANCEL_SHORTCUT" ]]; then + warn "Both arguments are $SHORTCUT, so the discard key was left out." + say "Pass two different combinations, or set it in Settings → Shortcuts." + CANCEL_SHORTCUT="" +fi + +register() { # which combination label + if out="$("$PY" "$DIR/dikte.py" shortcut install "$1" --combo "$2" 2>&1)"; then + ok "$3: $2" + else + warn "${out%%$'\n'*}" + fi +} +register toggle "$SHORTCUT" "Start and stop" +if [[ -n "$CANCEL_SHORTCUT" ]]; then + register cancel "$CANCEL_SHORTCUT" "Discard the recording" +fi + +# 7. What is left to do by hand --------------------------------------------- +# Two things this script cannot do, because macOS only takes them from the +# person at the keyboard. +echo +say "Two permissions have to be granted the first time, and macOS will ask:" +say " • Microphone, when the first recording starts" +say " • Accessibility, when the first transcript is pasted" +say "Both are under System Settings → Privacy & Security." +if ! system_profiler SPAudioDataType 2>/dev/null | grep -qi 'blackhole\|loopback\|soundflower'; then + echo + say "Recording a meeting also needs a loopback driver: macOS does not offer" + say "what the speakers are playing as something to record. Install one with:" + say " brew install blackhole-2ch" + say "Dictation does not need it." +fi + +echo +ok "Done. Start it with: open -a Dikte" +say "The settings window opens on first run: download a speech model, or add" +say "an OpenAI or OpenRouter key instead." +echo diff --git a/install.sh b/install.sh index c55b4c0..7d4f94f 100755 --- a/install.sh +++ b/install.sh @@ -3,6 +3,15 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" + +# Nothing below this line is true on a Mac: no XDG directories, no .desktop +# files, no shortcut registry, and an application is a bundle rather than a +# path. That is a second script rather than a branch through this one, and +# update.sh reaches it through here without having to know which it is on. +if [[ "$(uname -s)" == "Darwin" ]]; then + exec "$DIR/install-mac.sh" "$@" +fi + PY="$(command -v python3)" BIN_DIR="$HOME/.local/bin" APP_DIR="$HOME/.local/share/applications" diff --git a/paste.py b/paste.py index 030f203..d39198b 100644 --- a/paste.py +++ b/paste.py @@ -200,6 +200,14 @@ def _macos_api(): tool="CoreGraphics", error=exc)) from exc services.AXIsProcessTrusted.argtypes = [] services.AXIsProcessTrusted.restype = ctypes.c_bool + services.AXIsProcessTrustedWithOptions.argtypes = [ctypes.c_void_p] + services.AXIsProcessTrustedWithOptions.restype = ctypes.c_bool + core.CFDictionaryCreate.argtypes = [ + ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), + ctypes.POINTER(ctypes.c_void_p), ctypes.c_long, + ctypes.c_void_p, ctypes.c_void_p, + ] + core.CFDictionaryCreate.restype = ctypes.c_void_p services.CGEventCreateKeyboardEvent.argtypes = [ ctypes.c_void_p, ctypes.c_ushort, ctypes.c_bool, ] @@ -224,16 +232,60 @@ def _macos_trusted(): _asked_for_permission = False -def _ask_for_permission(): - """Open the one settings pane that grants it, and only the first time. +def _macos_prompt_options(services, core): + """{kAXTrustedCheckOptionPrompt: true}, as a CFDictionary, or 0. - Every dictation would otherwise reopen it until the box is ticked, which is - a window in the user's face on top of the paste that did not happen. + Built by hand because there is no Objective-C bridge here and this is the + only dictionary Dikte ever makes. Its own function so that a test can hand + back something without a framework to read the constants out of. + """ + keys = (ctypes.c_void_p * 1)( + ctypes.c_void_p.in_dll(services, "kAXTrustedCheckOptionPrompt")) + values = (ctypes.c_void_p * 1)( + ctypes.c_void_p.in_dll(core, "kCFBooleanTrue")) + return core.CFDictionaryCreate( + None, keys, values, 1, + ctypes.byref(ctypes.c_void_p.in_dll(core, "kCFTypeDictionaryKeyCallBacks")), + ctypes.byref(ctypes.c_void_p.in_dll(core, "kCFTypeDictionaryValueCallBacks")), + ) + + +def _macos_put_us_in_the_list(): + """Ask with the prompt, which is what creates the row to switch on. + + AXIsProcessTrusted only answers the question, and an application that has + only ever asked it is not in Accessibility at all: the pane opens on a list + Dikte is not in, and the only way through is the + button and a trip to the + Applications folder. Asking with kAXTrustedCheckOptionPrompt puts it there, + and macOS shows its own dialog with the button that opens the pane. + """ + services, core = _macos_api() + options = _macos_prompt_options(services, core) + if not options: + return + try: + services.AXIsProcessTrustedWithOptions(options) + finally: + core.CFRelease(options) + + +def _ask_for_permission(): + """Get Dikte into the Accessibility list, and only the first time. + + Every dictation would otherwise reopen the pane until the box is ticked, + which is a window in the user's face on top of the paste that did not + happen. """ global _asked_for_permission if _asked_for_permission: return _asked_for_permission = True + try: + _macos_put_us_in_the_list() + except (PasteError, OSError, ValueError): + # An older macOS, or a framework that would not load: the pane below is + # still worth opening, even if the row has to be added by hand. + pass try: subprocess.Popen( ["open", ("x-apple.systempreferences:com.apple.preference.security" diff --git a/paths.py b/paths.py new file mode 100644 index 0000000..8493746 --- /dev/null +++ b/paths.py @@ -0,0 +1,36 @@ +"""Where Dikte keeps its settings and its data, for the system it is on. + +A module of its own because two others need the answer and one of them cannot +ask the other: config.py imports ggml.py, so ggml.py cannot import config.py +back. Left alone, each worked it out for itself, and only config.py knew about +macOS. The result on a Mac was settings under `~/Library/Application Support` +and several gigabytes of models under `~/.local/share`, which is not a place a +Mac user looks, and not a place `uninstall.sh --purge` would have deleted from. + +Read at import, as the modules that use it already do. `directories()` takes the +platform as an argument so that a test can stand on the other one. +""" + +import os +import pathlib +import sys + + +def _xdg(var, default): + return pathlib.Path(os.environ.get(var) or os.path.expanduser(default)) + + +def directories(platform=None): + """(settings, data), in the two places this system keeps them. + + macOS keeps both in the one directory a Mac user's backup already knows + about. Everywhere else they are separate and follow the XDG variables. + """ + if (platform or sys.platform) == "darwin": + support = pathlib.Path.home() / "Library/Application Support/Dikte" + return support, support + return (_xdg("XDG_CONFIG_HOME", "~/.config") / "dikte", + _xdg("XDG_DATA_HOME", "~/.local/share") / "dikte") + + +CONFIG_DIR, DATA_DIR = directories() diff --git a/settings_ui.py b/settings_ui.py index 87d2611..b671931 100644 --- a/settings_ui.py +++ b/settings_ui.py @@ -671,6 +671,14 @@ class SettingsWindow(QDialog): self.local_threads = QSpinBox() self.local_threads.setRange(0, 64) self.local_threads.setSpecialValueText(t("Automatic")) + # A spin box asks for room for its numbers, and 64 is two characters: + # the word standing in for zero is what actually has to fit, and on + # macOS, where the stepper sits inside the frame, it does not. Widened + # to the word rather than to a number picked by eye, so that it still + # fits once the word is "Otomatik". + self.local_threads.setMinimumWidth( + self.local_threads.fontMetrics() + .horizontalAdvance(t("Automatic")) + 56) self.local_options = QWidget() options_form = QFormLayout(self.local_options) options_form.setContentsMargins(0, 0, 0, 0) @@ -1618,7 +1626,8 @@ class SettingsWindow(QDialog): # turns them off. for which, (box, _status, _missing) in self._shortcut_rows.items(): spec = hotkey.SHORTCUTS[which] - conf[spec.setting] = box.currentText().strip() or spec.fallback + conf[spec.setting] = (box.currentText().strip() + or hotkey.default_combo(which)) conf["evdev_hotkey"] = self.evdev_enabled.isChecked() conf["history_limit"] = self.history_limit.value() conf.save() @@ -1864,7 +1873,7 @@ class SettingsWindow(QDialog): def _install_shortcut(self, which): spec = hotkey.SHORTCUTS[which] box, _status, _missing = self._shortcut_rows[which] - combo = box.currentText().strip() or spec.fallback + combo = box.currentText().strip() or hotkey.default_combo(which) if not combo: QMessageBox.information(self, t("Shortcut"), t("Type a key combination first.")) diff --git a/tests/test_cli.py b/tests/test_cli.py index 9547df2..ebfce3b 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -333,6 +333,57 @@ class ConfigCommands(DikteTest): {"cleanup", "subtitles", "meeting", "agent"}) +class ShortcutStatus(DikteTest): + """Where the answer comes from, which is not one place on every system. + + macOS keeps no shortcut registry: a combination is held by the running + process and by nothing else, so a command line that reads its own idea of + "installed" reports every shortcut as missing while all of them work. + """ + + def run_cmd(self, func, **values): + with captured() as (out, err): + code = func(Options(**values)) + return code, out.getvalue(), err.getvalue() + + def status(self, reply): + with mock.patch.object(ipc, "send", return_value=reply) as send: + code, out, _ = self.run_cmd(cli.cmd_shortcut, shortcut="status", + json=True) + return code, json.loads(out), send + + def test_what_the_running_instance_holds_is_what_is_reported(self): + code, answer, _ = self.status({ + "shortcuts": {"toggle": "Ctrl+Option+Space", "cancel": None, + "ask": None, "meeting": None}, + "listener": True, + }) + self.assertEqual(code, 0) + self.assertEqual(answer["shortcuts"]["toggle"]["registered"], + "Ctrl+Option+Space") + self.assertIsNone(answer["shortcuts"]["cancel"]["registered"]) + self.assertIs(answer["listener"], True) + + def test_the_instance_is_the_one_asked(self): + _, _, send = self.status({"shortcuts": {}, "listener": False}) + send.assert_called_once_with("status") + + def test_nothing_running_falls_back_to_what_this_process_can_read(self): + """Which on Linux is the registry, and on macOS is nothing, correctly + so, because there the keys really are gone with the process.""" + code, answer, _ = self.status(None) + self.assertEqual(code, 0) + for name, spec in hotkey.SHORTCUTS.items(): + with self.subTest(name=name): + self.assertEqual(answer["shortcuts"][name]["registered"], + hotkey.shortcut_status(spec.desktop_id)) + + def test_the_configured_combination_is_reported_either_way(self): + code, answer, _ = self.status(None) + self.assertEqual(answer["shortcuts"]["toggle"]["configured"], + cfg.Config()["shortcut"]) + + class Providers(DikteTest): """The terminal reaches every provider the settings window does.""" diff --git a/tests/test_config.py b/tests/test_config.py index 31e153c..57482be 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -460,34 +460,6 @@ class Defaults(unittest.TestCase): paste.desktop().shortcuts[0]) -class Directories(unittest.TestCase): - """Where the settings and the recordings are kept, per system.""" - - def test_linux_keeps_them_apart_and_follows_xdg(self): - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", - "XDG_DATA_HOME": "/d"}): - config_dir, data_dir = cfg._directories("linux") - self.assertEqual(str(config_dir), "/c/dikte") - self.assertEqual(str(data_dir), "/d/dikte") - - def test_linux_without_the_variables_set(self): - with mock.patch.dict(os.environ, {}, clear=True): - config_dir, data_dir = cfg._directories("linux") - self.assertTrue(str(config_dir).endswith("/.config/dikte")) - self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) - - def test_a_mac_keeps_both_in_application_support(self): - config_dir, data_dir = cfg._directories("darwin") - self.assertEqual(config_dir, data_dir) - self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) - - def test_a_mac_does_not_read_the_xdg_variables(self): - """A Mac with them set from some other tool still stores in one place.""" - with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): - config_dir, _ = cfg._directories("darwin") - self.assertNotIn("/c", str(config_dir)) - - if __name__ == "__main__": unittest.main() diff --git a/tests/test_ggml.py b/tests/test_ggml.py index 161e688..983041e 100644 --- a/tests/test_ggml.py +++ b/tests/test_ggml.py @@ -226,7 +226,7 @@ class InstallProgram(Local): with fake_urlopen(listing): with self.assertRaises(ggml.LocalError) as caught: ggml.install_program(ggml.WHISPER) - self.assertIn("brew install whisper-cpp", str(caught.exception)) + self.assertIn("Build whisper-server yourself", str(caught.exception)) def test_a_mac_uses_the_native_llama_archive_instead_of_ubuntu(self): self.patch_attr(sys, "platform", "darwin") diff --git a/tests/test_hotkey.py b/tests/test_hotkey.py index c6ec50f..388c41b 100644 --- a/tests/test_hotkey.py +++ b/tests/test_hotkey.py @@ -78,6 +78,16 @@ class Table(unittest.TestCase): self.assertEqual([name for name, spec in hotkey.SHORTCUTS.items() if spec.fallback], ["toggle"]) + def test_the_fallback_a_mac_gets_is_not_one_macos_already_holds(self): + """Ctrl+Space switches the input source there and Cmd+Space is + Spotlight, so the table's own fallback is Linux's and only Linux's.""" + with mock.patch.object(hotkey.sys, "platform", "darwin"): + self.assertEqual(hotkey.default_combo("toggle"), "Ctrl+Option+Space") + self.assertEqual(hotkey.default_combo("cancel"), "") + with mock.patch.object(hotkey.sys, "platform", "linux"): + self.assertEqual(hotkey.default_combo("toggle"), "Ctrl+Space") + self.assertEqual(hotkey.default_combo("cancel"), "") + class ModsMatch(unittest.TestCase): """The combination has to be exact, or Ctrl+Space fires on Ctrl+Shift+Space.""" diff --git a/tests/test_paste.py b/tests/test_paste.py index 4bfe99f..85c13cd 100644 --- a/tests/test_paste.py +++ b/tests/test_paste.py @@ -275,11 +275,16 @@ class FakeCoreGraphics: self.flags = [] # (event, flags) self.posted = [] # (tap, event) self.released = [] + self.prompted = [] # the options dictionaries asked with # --- ApplicationServices def AXIsProcessTrusted(self): return self.trusted + def AXIsProcessTrustedWithOptions(self, options): + self.prompted.append(options) + return self.trusted + def CGEventCreateKeyboardEvent(self, source, keycode, down): self.made.append((keycode, down)) if self.makes is not None and len(self.made) > self.makes: @@ -301,10 +306,16 @@ class MacOS(ClipboardContract, DikteTest): platform = "darwin" here = paste.MACOS + # A stand-in for the CFDictionary: the real one is built out of constants + # read from the frameworks, which a fake has none of. + OPTIONS = 4242 + def setUp(self): super().setUp() self.api = FakeCoreGraphics() self.patch_attr(paste, "_macos_api", lambda: (self.api, self.api)) + self.patch_attr(paste, "_macos_prompt_options", + lambda services, core: self.OPTIONS) self.patch_attr(paste.time, "sleep", lambda seconds: None) # It opens the settings pane once per run; each test gets its own run. self.patch_attr(paste, "_asked_for_permission", False) @@ -385,6 +396,31 @@ class MacOS(ClipboardContract, DikteTest): paste.press("cmd+v") self.assertIn("Accessibility", str(caught.exception)) + def test_asking_is_what_puts_dikte_in_the_accessibility_list(self): + """Opening the pane is not enough on its own. + + AXIsProcessTrusted only answers the question; an application that has + never asked with the prompt is not in the list, so the pane opens on a + list Dikte is not in and the only way through is the + button. + """ + self.api.trusted = False + with self.assertRaises(paste.PasteError): + paste.press("cmd+v") + self.assertEqual(self.api.prompted, [self.OPTIONS]) + # The dictionary is ours to release, and nothing else made an event. + self.assertIn(self.OPTIONS, self.api.released) + + def test_it_asks_once_however_many_dictations_fail(self): + self.api.trusted = False + for _ in range(3): + with self.assertRaises(paste.PasteError): + paste.press("cmd+v") + self.assertEqual(len(self.api.prompted), 1) + + def test_a_trusted_process_is_never_prompted(self): + paste.press("cmd+v") + self.assertEqual(self.api.prompted, []) + def test_readiness_is_the_permission_rather_than_a_program(self): self.assertTrue(paste.paste_ready()) self.api.trusted = False diff --git a/tests/test_paths.py b/tests/test_paths.py new file mode 100644 index 0000000..d932e0f --- /dev/null +++ b/tests/test_paths.py @@ -0,0 +1,62 @@ +"""Where the settings and the data are kept, per system. + +Its own file because the answer has to be one answer: config.py and ggml.py +both need it, and when each worked it out for itself only one of them knew +about macOS. +""" + +import os +import unittest +from unittest import mock + +import config as cfg +import ggml +import paths + + +class Directories(unittest.TestCase): + def test_linux_keeps_them_apart_and_follows_xdg(self): + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c", + "XDG_DATA_HOME": "/d"}): + config_dir, data_dir = paths.directories("linux") + self.assertEqual(str(config_dir), "/c/dikte") + self.assertEqual(str(data_dir), "/d/dikte") + + def test_linux_without_the_variables_set(self): + with mock.patch.dict(os.environ, {}, clear=True): + config_dir, data_dir = paths.directories("linux") + self.assertTrue(str(config_dir).endswith("/.config/dikte")) + self.assertTrue(str(data_dir).endswith("/.local/share/dikte")) + + def test_a_mac_keeps_both_in_application_support(self): + config_dir, data_dir = paths.directories("darwin") + self.assertEqual(config_dir, data_dir) + self.assertTrue(str(config_dir).endswith("/Library/Application Support/Dikte")) + + def test_a_mac_does_not_read_the_xdg_variables(self): + """A Mac with them set from some other tool still stores in one place.""" + with mock.patch.dict(os.environ, {"XDG_CONFIG_HOME": "/c"}): + config_dir, _ = paths.directories("darwin") + self.assertNotIn("/c", str(config_dir)) + + +class OnePlace(unittest.TestCase): + """The programs and the models go where everything else goes. + + ggml.py used to read XDG_DATA_HOME itself, which is right on Linux and + wrong on a Mac: the settings and the dictations went to ~/Library while + several gigabytes of models went to ~/.local/share, where no Mac user looks + and where `uninstall.sh --purge` would never have found them. + """ + + def test_the_models_live_under_the_data_directory(self): + self.assertEqual(ggml.DATA_DIR, paths.DATA_DIR) + self.assertEqual(ggml.MODELS_DIR.parent, paths.DATA_DIR) + self.assertEqual(ggml.BIN_DIR.parent, paths.DATA_DIR) + + def test_config_and_ggml_cannot_disagree(self): + self.assertEqual(cfg.DATA_DIR, ggml.DATA_DIR) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_ui.py b/tests/test_ui.py index 518166f..e069ad0 100644 --- a/tests/test_ui.py +++ b/tests/test_ui.py @@ -180,13 +180,19 @@ class Settings(DikteTest): def test_emptying_a_shortcut_turns_it_off_but_not_the_toggle(self): """The application is unusable without the toggle, so that one box - falls back. The rest stay empty, which is how they are switched off.""" + falls back. The rest stay empty, which is how they are switched off. + + Which combination it falls back to is the platform's and is pinned in + test_hotkey; MacSettings runs this too, and there the answer is not + Ctrl+Space. + """ conf = cfg.Config() window = self.window(conf) for box, _status, _missing in window._shortcut_rows.values(): box.setCurrentText("") window._save() - self.assertEqual(conf["shortcut"], "Ctrl+Space") + self.assertTrue(conf["shortcut"]) + self.assertEqual(conf["shortcut"], hotkey.default_combo("toggle")) self.assertEqual(conf["cancel_shortcut"], "") self.assertEqual(conf["assistant_shortcut"], "") self.assertEqual(conf["meeting_shortcut"], "") diff --git a/trayicon.py b/trayicon.py new file mode 100644 index 0000000..b5aafc2 --- /dev/null +++ b/trayicon.py @@ -0,0 +1,213 @@ +"""The three tray icons, drawn here for systems that have no icon theme. + +Linux hands out `audio-input-microphone`, `media-record` and `view-refresh` +from whatever icon theme is installed, and Qt finds them through +QIcon.fromTheme. macOS has no such registry: fromTheme returns a null icon +there, and a null icon in the menu bar is an item you cannot see, which is the +whole of Dikte's interface gone. So the same three shapes are drawn here, and +used whenever the theme has nothing to offer. + +They are drawn as template images: one colour, transparent everywhere else, +with isMask set. That is what lets macOS invert them for a dark menu bar and +grey them while the menu is open, and it is why the shapes are outlines rather +than the coloured glyphs a Linux theme would give. +""" + +import pathlib +import sys + +from PyQt6.QtCore import QPointF, QRectF, Qt +from PyQt6.QtGui import (QColor, QIcon, QLinearGradient, QPainter, QPainterPath, + QPen, QPixmap) + +# What a Mac menu bar asks for: 22 points, at 1x and at 2x. Both are put in the +# icon rather than one being scaled, because a scaled stroke goes soft. +SIZES = (22, 44) +# Drawn in black; the mask throws the colour away and keeps the coverage, and +# on a system that does not do masks black is still the right ink for a light +# panel and readable on a dark one. +INK = QColor(0, 0, 0) + + +def _canvas(size): + pixmap = QPixmap(size, size) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + return pixmap, painter + + +def _paint_microphone(painter, size, ink): + """A capsule on a stand: idle, and the application's own mark. + + The colour is a parameter because the same glyph is the tray stencil, where + it is black and then masked, and the white one on the application icon. + """ + unit = size / 22.0 + pen = QPen(ink, 1.6 * unit) + pen.setCapStyle(Qt.PenCapStyle.RoundCap) + painter.setPen(pen) + painter.setBrush(ink) + # The capsule, held away from the edges so the stroke below has room. + painter.drawRoundedRect( + QRectF(8.2 * unit, 3.4 * unit, 5.6 * unit, 10.4 * unit), + 2.8 * unit, 2.8 * unit, + ) + painter.setBrush(Qt.BrushStyle.NoBrush) + # The arc that cradles it, and the post and foot under that. + painter.drawArc( + QRectF(5.4 * unit, 6.6 * unit, 11.2 * unit, 10.4 * unit), + 180 * 16, 180 * 16, + ) + painter.drawLine(QPointF(11 * unit, 16.8 * unit), QPointF(11 * unit, 19 * unit)) + painter.drawLine(QPointF(7.6 * unit, 19 * unit), QPointF(14.4 * unit, 19 * unit)) + + +def _microphone(painter, size): + _paint_microphone(painter, size, INK) + + +def _record(painter, size): + """A filled dot: recording, and the same red dot the overlay shows.""" + unit = size / 22.0 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(INK) + painter.drawEllipse(QPointF(11 * unit, 11 * unit), 6.4 * unit, 6.4 * unit) + + +def _working(painter, size): + """An arrow chasing its own circle: transcribing, cleaning up, thinking.""" + unit = size / 22.0 + pen = QPen(INK, 2.0 * unit) + pen.setCapStyle(Qt.PenCapStyle.FlatCap) + painter.setPen(pen) + painter.setBrush(Qt.BrushStyle.NoBrush) + ring = QRectF(4.4 * unit, 4.4 * unit, 13.2 * unit, 13.2 * unit) + # Three quarters of the way round, leaving the gap the head sits in. + painter.drawArc(ring, 90 * 16, -280 * 16) + + # The head, as a filled triangle at the open end rather than two more + # strokes: at 22 points a drawn arrowhead closes up into a blob. + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(INK) + head = QPainterPath() + head.moveTo(QPointF(11.0 * unit, 1.6 * unit)) + head.lineTo(QPointF(11.0 * unit, 7.2 * unit)) + head.lineTo(QPointF(15.8 * unit, 4.4 * unit)) + head.closeSubpath() + painter.drawPath(head) + + +# The names Linux themes use, which are what dikte.py asks for either way. +SHAPES = { + "audio-input-microphone": _microphone, + "media-record": _record, + "view-refresh": _working, +} + +_cache = {} + + +def icon(name): + """The named icon drawn here, or a null QIcon when it is not one of ours. + + Cached because the tray is refreshed on every state change and every one of + those would otherwise redraw three pixmaps. A QIcon is cheap to copy and the + pixmaps inside it are shared, so handing the same object out is safe. + """ + shape = SHAPES.get(name) + if shape is None: + return QIcon() + if name in _cache: + return _cache[name] + + result = QIcon() + for size in SIZES: + pixmap, painter = _canvas(size) + try: + shape(painter, size) + finally: + painter.end() + result.addPixmap(pixmap) + # The line that makes it a template image: macOS then owns the colour, and + # the icon follows the menu bar into dark mode instead of staying black. + result.setIsMask(True) + _cache[name] = result + return result + + +# --- the application icon -------------------------------------------------- +# +# The menu bar wants a flat stencil; the Finder, the Dock and the permission +# dialogs want a picture. Same microphone, on a ground of its own, and drawn +# here as well so that `install-mac.sh` has an .icns to build without a binary +# blob living in the repository. + +# What iconutil expects to find in an .iconset: each of these at 1x and 2x. +APP_ICON_SIZES = (16, 32, 128, 256, 512) + + +def app_pixmap(size): + """The application icon at one size: a white microphone on a blue tile.""" + pixmap, painter = _canvas(size) + try: + unit = size / 22.0 + # macOS rounds and shadows the tile itself for some icon styles but not + # for a plain .icns, so the shape is drawn: the squircle radius Apple + # uses is close enough to 22% of the side. + ground = QLinearGradient(0, 0, 0, size) + ground.setColorAt(0.0, QColor(0x3B, 0x82, 0xF6)) + ground.setColorAt(1.0, QColor(0x1D, 0x4E, 0xD8)) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(ground) + inset = 1.0 * unit + painter.drawRoundedRect( + QRectF(inset, inset, size - 2 * inset, size - 2 * inset), + 4.4 * unit, 4.4 * unit, + ) + # The same glyph as the tray, in white and a little smaller so it sits + # inside the tile rather than against its edges. + painter.save() + painter.translate(size / 2.0, size / 2.0) + painter.scale(0.64, 0.64) + painter.translate(-size / 2.0, -size / 2.0) + _paint_microphone(painter, size, QColor(0xFF, 0xFF, 0xFF)) + painter.restore() + finally: + painter.end() + return pixmap + + +def write_iconset(directory): + """Write the PNGs `iconutil -c icns` reads. The directory it wrote to.""" + directory = pathlib.Path(directory) + directory.mkdir(parents=True, exist_ok=True) + for size in APP_ICON_SIZES: + for scale in (1, 2): + name = f"icon_{size}x{size}{'@2x' if scale == 2 else ''}.png" + app_pixmap(size * scale).save(str(directory / name), "PNG") + return directory + + +def _main(argv): + """`python3 trayicon.py .iconset`, which install-mac.sh calls. + + A QGuiApplication has to exist before a QPixmap can, and offscreen because + this runs from a shell script with no window to open. + """ + if len(argv) != 2: + print("usage: trayicon.py .iconset", file=sys.stderr) + return 2 + from PyQt6.QtGui import QGuiApplication + QGuiApplication.setAttribute( + Qt.ApplicationAttribute.AA_UseSoftwareOpenGL, True) + app = QGuiApplication(["dikte-icon", "-platform", "offscreen"]) + try: + print(write_iconset(argv[1])) + finally: + del app + return 0 + + +if __name__ == "__main__": + sys.exit(_main(sys.argv)) diff --git a/uninstall.sh b/uninstall.sh index dfbdaa1..b7e740b 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -5,13 +5,34 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PY="$(command -v python3 || true)" USER_NAME="$(id -un)" BIN_DIR="$HOME/.local/bin" -APP_DIR="$HOME/.local/share/applications" -AUTOSTART_DIR="$HOME/.config/autostart" -CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" -DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" + +# What an installation is made of differs by platform, but what --purge deletes +# and what it asks before deleting it does not, which is why this is one script +# with two sets of paths rather than two scripts. +if [[ "$(uname -s)" == "Darwin" ]]; then + MACOS=1 + MAC_APP="$HOME/Applications/Dikte.app" + AGENT_ID="io.github.yusufipk.dikte" + AGENT="$HOME/Library/LaunchAgents/$AGENT_ID.plist" + CONFIG_DIR="$HOME/Library/Application Support/Dikte" + DATA_DIR="$CONFIG_DIR" + # The interpreter this installation was put on, which is not necessarily the + # python3 on PATH: Apple's is 3.9 and cannot run Dikte. The wrapper written + # by install-mac.sh names it, so it is read back out of there. + # `|| true` because there may be no wrapper to read: pipefail would otherwise + # make a missing file the end of the script rather than a question answered no. + PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$BIN_DIR/dikte" 2>/dev/null | head -1 || true)" + [[ -x "$PY" ]] || PY="$(command -v python3 || true)" +else + MACOS=0 + APP_DIR="$HOME/.local/share/applications" + AUTOSTART_DIR="$HOME/.config/autostart" + CONFIG_DIR="${XDG_CONFIG_HOME:-$HOME/.config}/dikte" + DATA_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/dikte" + PY="$(command -v python3 || true)" +fi PURGE=0 ASSUME_YES=0 @@ -63,8 +84,12 @@ echo "──────────────────" # 1. Global shortcuts ------------------------------------------------------ # Handed to Dikte while it can still run, because it is the half that knows -# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. -if [[ -n "$PY" ]] && python3 -c 'import PyQt6.QtWidgets' 2>/dev/null; then +# whether they went into KDE's kglobalshortcutsrc or GNOME's gsettings. macOS +# keeps no registry: the combinations are held by the running process and are +# gone the moment it stops, so there is nothing here to take back. +if ((MACOS)); then + say "Nothing to unregister: macOS shortcuts live only while Dikte runs." +elif [[ -n "$PY" ]] && "$PY" -c 'import PyQt6.QtWidgets' 2>/dev/null; then for which in toggle cancel ask meeting; do "$PY" "$DIR/dikte.py" shortcut remove "$which" >/dev/null 2>&1 || true done @@ -89,25 +114,49 @@ if pgrep -u "$USER_NAME" -f 'dikte\.py' >/dev/null 2>&1; then fi # 3. Launchers ------------------------------------------------------------- -# Only our own symlink goes: a file of the same name that somebody else put -# there is not ours to delete. +# Only what this installer wrote goes: a file of the same name that somebody +# else put there is not ours to delete. On Linux ours is a symlink; on macOS it +# is a generated wrapper, which says so in its second line. if [[ -L "$BIN_DIR/dikte" ]]; then remove "$BIN_DIR/dikte" +elif ((MACOS)) && grep -q 'Written by install-mac.sh' "$BIN_DIR/dikte" 2>/dev/null; then + remove "$BIN_DIR/dikte" elif [[ -e "$BIN_DIR/dikte" ]]; then - warn "$BIN_DIR/dikte is not our symlink, leaving it alone" + warn "$BIN_DIR/dikte is not ours, leaving it alone" else gone "Was not there: $BIN_DIR/dikte" fi -remove "$APP_DIR/dikte.desktop" -remove "$AUTOSTART_DIR/dikte.desktop" -# Removing the shortcut takes its desktop file with it, but an install from -# before this script existed may have left one behind on a desktop that never -# used them. -for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do - if [[ -e "$APP_DIR/$id.desktop" ]]; then - remove "$APP_DIR/$id.desktop" + +if ((MACOS)); then + # The login item first: bootout while the plist is still there, because + # launchctl is told which job by the file as much as by the label. + if [[ -e "$AGENT" ]]; then + launchctl bootout "gui/$(id -u)/$AGENT_ID" >/dev/null 2>&1 || true fi -done + remove "$AGENT" + if [[ -d "$MAC_APP" ]]; then + rm -rf "$MAC_APP" + ok "Removed $MAC_APP" + else + gone "Was not there: $MAC_APP" + fi + # macOS keeps the permissions filed against the bundle that asked for them, + # and deleting the bundle does not withdraw them. + say "Microphone and Accessibility are still granted to Dikte; take them back" + say "under System Settings → Privacy & Security if you want them gone." +fi +if ((!MACOS)); then + remove "$APP_DIR/dikte.desktop" + remove "$AUTOSTART_DIR/dikte.desktop" + # Removing the shortcut takes its desktop file with it, but an install from + # before this script existed may have left one behind on a desktop that never + # used them. + for id in dikte-toggle dikte-cancel dikte-ask dikte-meeting; do + if [[ -e "$APP_DIR/$id.desktop" ]]; then + remove "$APP_DIR/$id.desktop" + fi + done +fi # 4. Settings and dictations ----------------------------------------------- echo @@ -142,6 +191,11 @@ fi if ((PURGE)); then rm -rf "$CONFIG_DIR" "$DATA_DIR" ok "Settings and dictations deleted" +elif [[ "$CONFIG_DIR" == "$DATA_DIR" ]]; then + # macOS keeps both in the one directory a Mac user's backup already knows + # about, so naming it twice would only look like two things were kept. + say "Settings and dictations kept: $CONFIG_DIR" + say "Delete them too with: ./uninstall.sh --purge" else say "Settings kept: $CONFIG_DIR" say "Dictations kept: $DATA_DIR" diff --git a/update.sh b/update.sh index 9194407..18cc40d 100755 --- a/update.sh +++ b/update.sh @@ -3,9 +3,22 @@ set -euo pipefail DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PY="$(command -v python3 || true)" USER_NAME="$(id -un)" +if [[ "$(uname -s)" == "Darwin" ]]; then + # Apple's python3 is 3.9 and will stay 3.9, so the one on PATH is the wrong + # question here. install-mac.sh names the interpreter it installed against in + # the wrapper it wrote, and that is the one this installation runs on. + # `|| true` because there may be no wrapper to read: pipefail would otherwise + # make a missing file the end of the script rather than a question answered no. + PY="$(sed -n 's/^exec "\([^"]*\)".*/\1/p' "$HOME/.local/bin/dikte" 2>/dev/null | head -1 || true)" + [[ -x "$PY" ]] || PY="$(command -v python3 || true)" + DEFAULT_SHORTCUT="Ctrl+Option+Space" +else + PY="$(command -v python3 || true)" + DEFAULT_SHORTCUT="Ctrl+Space" +fi + say() { printf ' %s\n' "$1"; } ok() { printf ' \033[32m✓\033[0m %s\n' "$1"; } warn() { printf ' \033[33m!\033[0m %s\n' "$1"; } @@ -83,7 +96,7 @@ echo shortcut="$(setting shortcut)" cancel_shortcut="$(setting cancel_shortcut)" # Positional, so a chosen discard key cannot be passed without the other one. -"$DIR/install.sh" "${shortcut:-Ctrl+Space}" "${cancel_shortcut:-}" +"$DIR/install.sh" "${shortcut:-$DEFAULT_SHORTCUT}" "${cancel_shortcut:-}" # 5. The running instance --------------------------------------------------- # It is still running the code from before the pull. From a6fab18a225e393c4a6ee4b9dddbc8f190ff4d8c Mon Sep 17 00:00:00 2001 From: yusufipk Date: Sun, 16 Aug 2026 09:50:43 +0300 Subject: [PATCH 3/3] Keep the meeting a quiet microphone gave us, and read both captures at once A recording is never deleted for being disappointing. A microphone that handed over nothing still leaves the right channel, which is everyone else, and an hour of them is worth more than the empty channel costs; the one thing the user cannot get back is the half that was there. So the exact-zero check stays and stops throwing the file away: it says what the microphone did, in a tray warning next to the recording being written up, and the minutes are produced from what there is. Reading the two capture pipes in turn from one thread put the failure it was meant to fix back in a worse place. A microphone that stops delivering leaves that read waiting forever, and the far side is not read either until its pipe fills and its ffmpeg stops writing into it: the meeting freezes, the levels sit still, and nothing is said for as long as nobody looks. Each stream now has a reader of its own and a queue, so neither can hold the other up, and a side that has said nothing for STALL_SECONDS ends the recording the way a dead ffmpeg already did, out loud and keeping what was captured. Which system needs how many processes belongs in the table with everything else that differs, so meeting() returns the list of commands it takes: one on PulseAudio, one per device on a Mac. meeting_commands() is the chooser again rather than a function with a Mac inside it, and the empty entry in COREAUDIO is gone. The two AVFoundation targets are resolved against a single device listing, which costs one ffmpeg run instead of two and cannot see the indexes renumber between the microphone and the far side. --- audio.py | 180 +++++++++++++++++++++++++++++--------------- dikte.py | 6 ++ i18n.py | 10 ++- tests/test_audio.py | 112 ++++++++++++++++++++++----- 4 files changed, 225 insertions(+), 83 deletions(-) diff --git a/audio.py b/audio.py index bf15ff6..511bdf1 100644 --- a/audio.py +++ b/audio.py @@ -2,15 +2,16 @@ 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. 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. +chooser picks between them. """ import array @@ -18,6 +19,7 @@ import collections import json import math import os +import queue import re import shutil import signal @@ -39,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.""" @@ -193,17 +210,12 @@ def recording_command(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. + 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. """ - if sound() is COREAUDIO: - return [ - _avfoundation_meeting_capture(mic_target), - _avfoundation_meeting_capture(system_target), - ] - return [sound().meeting(mic_target, system_target)] + return sound().meeting(mic_target, system_target) class AudioDeviceError(RuntimeError): @@ -223,11 +235,11 @@ 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 @@ -277,10 +289,10 @@ class MeetingRecorder(QObject): self._procs.append(subprocess.Popen( command, stdout=subprocess.PIPE, stderr=log, bufsize=0 )) - self._proc = self._procs[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._proc = None self._procs = [] self._close_file() self._drop_log() @@ -326,13 +338,21 @@ class MeetingRecorder(QObject): pass def _pump_split(self): - left = self._procs[0].stdout - right = self._procs[1].stdout + # 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 = _read_exact(left, block) - theirs = _read_exact(right, block) + 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 @@ -407,7 +427,6 @@ class MeetingRecorder(QObject): 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 @@ -422,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. @@ -446,21 +465,19 @@ class MeetingRecorder(QObject): 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() + # 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): @@ -496,20 +513,40 @@ def stereo_levels(chunk): 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]) + """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") - stereo_samples.extend( - sample for pair in zip(left_samples[:frames], right_samples[:frames]) - for sample in pair - ) + 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() @@ -522,9 +559,13 @@ def _read_exact(stream, size): 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 sum(sample == 0 for sample in samples) + return samples def _peak(samples): @@ -535,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];" @@ -600,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(): @@ -674,8 +717,20 @@ def _avfoundation_record(target): ] -def _avfoundation_meeting_capture(target): - target = _resolve_avfoundation_target(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", @@ -725,7 +780,7 @@ def _avfoundation_named_inputs(): for _index, description in _avfoundation_inputs()] -def _resolve_avfoundation_target(target): +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" @@ -734,7 +789,9 @@ def _resolve_avfoundation_target(target): "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 inputs is None: + inputs = _avfoundation_inputs() + matches = [index for index, description in inputs if description == target] if not matches: raise AudioDeviceError(t( @@ -758,9 +815,10 @@ def _avfoundation_default_output(): 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", ) @@ -775,7 +833,7 @@ PULSE = Sound( COREAUDIO = Sound( record=_avfoundation_record, - meeting=None, # two separate capture processes; see meeting_commands() + meeting=_avfoundation_meeting, 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 diff --git a/dikte.py b/dikte.py index cda7f54..3d72a09 100755 --- a/dikte.py +++ b/dikte.py @@ -123,6 +123,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) @@ -721,6 +722,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 52ab91c..c1495e3 100644 --- a/i18n.py +++ b/i18n.py @@ -566,10 +566,12 @@ TR = { "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 macOS microphone stopped delivering audio ({percent}% was empty). The " - "unusable recording was discarded; reconnect the device and try again.": - "macOS mikrofonu ses iletmeyi durdurdu (kaydın %{percent} kadarı boştu). " - "Kullanılamaz kayıt silindi; aygıtı yeniden bağlayıp tekrar dene.", + "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 7a6a09e..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 @@ -251,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.""" @@ -486,8 +515,10 @@ class MeetingCommands(unittest.TestCase): def commands(self, platform, mic="", system="them"): with mock.patch.object(sys, "platform", platform), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=lambda target: target or "default"): + 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): @@ -529,23 +560,41 @@ class MeetingCommands(unittest.TestCase): 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 = [], [] + 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"), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=("2", "1")), \ + 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, recorder, stopped, failed, processes, popen + return path, warnings, stopped, failed, processes, popen def test_the_two_capture_processes_become_one_stereo_file(self): path, _, stopped, failed, _, _ = self.record( @@ -567,14 +616,42 @@ class MacMeetingRecorder(OnMacOS, DikteTest): self.assertIn(":2", commands[0]) self.assertIn(":1", commands[1]) - def test_an_unusable_mostly_empty_microphone_is_not_transcribed(self): - path, _, stopped, failed, _, _ = self.record( + 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(stopped, []) - self.assertEqual(len(failed), 1) - self.assertIn("empty", failed[0]) - self.assertFalse(os.path.exists(path)) + 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)) @@ -584,22 +661,21 @@ class MacMeetingRecorder(OnMacOS, DikteTest): recorder = audio.MeetingRecorder() failed = [] recorder.failed.connect(failed.append) - with only_these_tools("ffmpeg"), \ - mock.patch.object(audio, "_avfoundation_inputs", return_value=[]), \ + 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"), \ - mock.patch.object(audio, "_resolve_avfoundation_target", - side_effect=("2", "1")), \ + 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")