diff --git a/qt/aqt/_macos_helper.py b/qt/aqt/_macos_helper.py new file mode 100644 index 000000000..f0c14d999 --- /dev/null +++ b/qt/aqt/_macos_helper.py @@ -0,0 +1,55 @@ +# Copyright: Ankitects Pty Ltd and contributors +# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +from __future__ import annotations + +import os +import sys +from ctypes import CDLL, CFUNCTYPE, c_char_p +from typing import Callable + +import aqt + + +class _MacOSHelper: + def __init__(self) -> None: + if getattr(sys, "frozen", False): + path = os.path.join(sys.prefix, "libankihelper.dylib") + else: + path = os.path.join( + aqt.utils.aqt_data_folder(), "lib", "libankihelper.dylib" + ) + + self._dll = CDLL(path) + + def system_is_dark(self) -> bool: + return self._dll.system_is_dark() + + def set_darkmode_enabled(self, enabled: bool) -> bool: + return self._dll.set_darkmode_enabled(enabled) + + def start_wav_record(self, path: str, on_error: Callable[[str], None]) -> None: + global _on_audio_error + _on_audio_error = on_error + self._dll.start_wav_record(path.encode("utf8"), _audio_error_callback) + + def end_wav_record(self) -> None: + "On completion, file should be saved if no error has arrived." + self._dll.end_wav_record() + + +# this must not be overwritten or deallocated +@CFUNCTYPE(None, c_char_p) # type: ignore +def _audio_error_callback(msg: str) -> None: + if handler := _on_audio_error: + handler(msg) + + +_on_audio_error: Callable[[str], None] | None = None + +macos_helper: _MacOSHelper | None = None +if sys.platform == "darwin": + try: + macos_helper = _MacOSHelper() + except Exception as e: + print("macos_helper:", e) diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 00e6f19b6..1f3e5e594 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -25,6 +25,7 @@ from anki.cards import Card from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag from anki.utils import is_lin, is_mac, is_win, namedtmp from aqt import gui_hooks +from aqt._macos_helper import macos_helper from aqt.mpv import MPV, MPVBase, MPVCommandError from aqt.qt import * from aqt.taskman import TaskManager @@ -607,6 +608,30 @@ class QtAudioInputRecorder(Recorder): t.start(500) +# Native macOS recording +########################################################################## + + +class NativeMacRecorder(Recorder): + def __init__(self, output_path: str) -> None: + super().__init__(output_path) + self._error: str | None = None + + def _on_error(self, msg: str) -> None: + self._error = msg + + def start(self, on_done: Callable[[], None]) -> None: + self._error = None + assert macos_helper + macos_helper.start_wav_record(self.output_path, self._on_error) + super().start(on_done) + + def stop(self, on_done: Callable[[str], None]) -> None: + assert macos_helper + macos_helper.end_wav_record() + Recorder.stop(self, on_done) + + # Recording dialog ########################################################################## @@ -662,9 +687,14 @@ class RecordDialog(QDialog): def _start_recording(self) -> None: if qtmajor > 5: - self._recorder = QtAudioInputRecorder( - namedtmp("rec.wav"), self.mw, self._parent - ) + if macos_helper and platform.machine() == "arm64": + self._recorder = NativeMacRecorder( + namedtmp("rec.wav"), + ) + else: + self._recorder = QtAudioInputRecorder( + namedtmp("rec.wav"), self.mw, self._parent + ) else: from aqt.qt.qt5_audio import QtAudioInputRecorder as Qt5Recorder @@ -706,10 +736,6 @@ class RecordDialog(QDialog): def record_audio( parent: QWidget, mw: aqt.AnkiQt, encode: bool, on_done: Callable[[str], None] ) -> None: - if sys.platform.startswith("darwin") and platform.machine() == "arm64": - showWarning("Recording currently only works in Anki's Intel build") - return - def after_record(path: str) -> None: if not encode: on_done(path) diff --git a/qt/aqt/theme.py b/qt/aqt/theme.py index bf01be109..465c9ce05 100644 --- a/qt/aqt/theme.py +++ b/qt/aqt/theme.py @@ -4,11 +4,8 @@ from __future__ import annotations import enum -import os import platform import subprocess -import sys -from ctypes import CDLL from dataclasses import dataclass import aqt @@ -335,27 +332,20 @@ def get_windows_dark_mode() -> bool: def set_macos_dark_mode(enabled: bool) -> bool: "True if setting successful." - if not is_mac: + from aqt._macos_helper import macos_helper + + if not macos_helper: return False - try: - _ankihelper().set_darkmode_enabled(enabled) - return True - except Exception as e: - # swallow exceptions, as library will fail on macOS 10.13 - print(e) - return False + return macos_helper.set_darkmode_enabled(enabled) def get_macos_dark_mode() -> bool: "True if macOS system is currently in dark mode." - if not is_mac: - return False - try: - return _ankihelper().system_is_dark() - except Exception as e: - # swallow exceptions, as library will fail on macOS 10.13 - print(e) + from aqt._macos_helper import macos_helper + + if not macos_helper: return False + return macos_helper.system_is_dark() def get_linux_dark_mode() -> bool: @@ -378,19 +368,4 @@ def get_linux_dark_mode() -> bool: return "-dark" in process.stdout.lower() -_ankihelper_dll: CDLL | None = None - - -def _ankihelper() -> CDLL: - global _ankihelper_dll - if _ankihelper_dll: - return _ankihelper_dll - if getattr(sys, "frozen", False): - path = os.path.join(sys.prefix, "libankihelper.dylib") - else: - path = os.path.join(aqt.utils.aqt_data_folder(), "lib", "libankihelper.dylib") - _ankihelper_dll = CDLL(path) - return _ankihelper_dll - - theme_manager = ThemeManager() diff --git a/qt/mac/BUILD.bazel b/qt/mac/BUILD.bazel index 45948e07d..b28232188 100644 --- a/qt/mac/BUILD.bazel +++ b/qt/mac/BUILD.bazel @@ -9,6 +9,7 @@ genrule( srcs = glob(["*.swift"]), outs = ["libankihelper.dylib"], cmd = "$(location :helper_build) $@ $(COMPILATION_MODE) $(SRCS)", + message = "Compiling Swift dylib", tags = ["manual"], tools = [":helper_build"], visibility = ["//qt:__subpackages__"], diff --git a/qt/mac/ankihelper.xcodeproj/project.pbxproj b/qt/mac/ankihelper.xcodeproj/project.pbxproj index 637f03e40..8232fba8e 100644 --- a/qt/mac/ankihelper.xcodeproj/project.pbxproj +++ b/qt/mac/ankihelper.xcodeproj/project.pbxproj @@ -8,10 +8,12 @@ /* Begin PBXBuildFile section */ 137892AC275D90FC009D0B6E /* theme.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137892AB275D90FC009D0B6E /* theme.swift */; }; + 137892B0275DAE22009D0B6E /* record.swift in Sources */ = {isa = PBXBuildFile; fileRef = 137892AF275DAE22009D0B6E /* record.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ 137892AB275D90FC009D0B6E /* theme.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = theme.swift; sourceTree = ""; }; + 137892AF275DAE22009D0B6E /* record.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = record.swift; sourceTree = ""; }; 138B770F2746137F003A3E4F /* libankihelper.dylib */ = {isa = PBXFileReference; explicitFileType = "compiled.mach-o.dylib"; includeInIndex = 0; path = libankihelper.dylib; sourceTree = BUILT_PRODUCTS_DIR; }; /* End PBXFileReference section */ @@ -30,6 +32,7 @@ isa = PBXGroup; children = ( 137892AB275D90FC009D0B6E /* theme.swift */, + 137892AF275DAE22009D0B6E /* record.swift */, 138B77102746137F003A3E4F /* Products */, ); sourceTree = ""; @@ -110,6 +113,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 137892B0275DAE22009D0B6E /* record.swift in Sources */, 137892AC275D90FC009D0B6E /* theme.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; diff --git a/qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcuserdata/dae.xcuserdatad/UserInterfaceState.xcuserstate b/qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcuserdata/dae.xcuserdatad/UserInterfaceState.xcuserstate index ca596a1eb..ea5377348 100644 Binary files a/qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcuserdata/dae.xcuserdatad/UserInterfaceState.xcuserstate and b/qt/mac/ankihelper.xcodeproj/project.xcworkspace/xcuserdata/dae.xcuserdatad/UserInterfaceState.xcuserstate differ diff --git a/qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/ankihelper.xcscheme b/qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/ankihelper.xcscheme new file mode 100644 index 000000000..998a3113d --- /dev/null +++ b/qt/mac/ankihelper.xcodeproj/xcuserdata/dae.xcuserdatad/xcschemes/ankihelper.xcscheme @@ -0,0 +1,23 @@ + + + + + + + + + + + + + diff --git a/qt/mac/record.swift b/qt/mac/record.swift new file mode 100644 index 000000000..1c22d71ed --- /dev/null +++ b/qt/mac/record.swift @@ -0,0 +1,117 @@ +// Copyright: Ankitects Pty Ltd and contributors +// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + +import Foundation +import AVKit + +enum RecordError: Error { + case noPermission + case audioFormat + case recordInvoke + case stoppedWithFailure + case encodingFailure +} + +@_cdecl("start_wav_record") +public func startWavRecord( + path: UnsafePointer, + onError: @escaping @convention(c) (UnsafePointer) -> Void +) { + let url = URL(fileURLWithPath: String(cString: path)) + AudioRecorder.shared.beginRecording(url: url, onError: { error in + error.localizedDescription.withCString { cString in + onError(cString) + } + }) +} + +@_cdecl("end_wav_record") +public func endWavRecord() { + AudioRecorder.shared.endRecording() +} + + +private class AudioRecorder: NSObject, AVAudioRecorderDelegate { + static let shared = AudioRecorder() + + private var audioRecorder: AVAudioRecorder? + private var onError: ((RecordError) -> Void)? + + func beginRecording(url: URL, onError: @escaping (Error) -> Void) { + self.endRecording() + + requestPermission { success in + if !success { + onError(RecordError.noPermission) + return + } + + do { + try self.beginRecordingInner(url: url) + } catch { + onError(error) + return + } + self.onError = onError + } + + } + + func endRecording() { + if let recorder = audioRecorder { + recorder.stop() + } + audioRecorder = nil + onError = nil + } + + /// Request permission, then call provided callback (true on success). + private func requestPermission(completionHandler: @escaping (Bool) -> Void) { + switch AVCaptureDevice.authorizationStatus(for: .audio) { + case .notDetermined: + AVCaptureDevice.requestAccess( + for: .audio, + completionHandler: completionHandler + ) + return + case .authorized: + completionHandler(true) + return + case .restricted: + print("recording restricted") + case .denied: + print("recording denied") + @unknown default: + print("recording unknown permission") + } + completionHandler(false) + } + + private func beginRecordingInner(url: URL) throws { + guard let audioFormat = AVAudioFormat.init( + commonFormat: .pcmFormatInt16, + sampleRate: 44100, + channels: 1, + interleaved: true + ) else { + throw RecordError.audioFormat + } + let recorder = try AVAudioRecorder(url: url, format: audioFormat) + if !recorder.record() { + throw RecordError.recordInvoke + } + audioRecorder = recorder + } + + + func audioRecorderDidFinishRecording(_ recorder: AVAudioRecorder, successfully flag: Bool) { + if !flag { + onError?(.stoppedWithFailure) + } + } + + + func audioRecorderEncodeErrorDidOccur(_ recorder: AVAudioRecorder, error: Error?) { + onError?(.encodingFailure) + } +}