implement a basic native macOS audio recorder

This was motivated by the fact that recording was crashing on the native
M1 build. That ended up being mostly a PEBKAC problem - turns out the
Mac Mini has no built-in microphone 🤦.

I still thinks this has some value though - it doesn't crash in such
cases, and probably doesn't suffer from the problem shown in this thread
either:

https://forums.ankiweb.net/t/anki-crashes-when-trying-to-record-on-mac/14764

For now, this is only enabled when running on arm64. If it turns out to
be reliable, it could be offered as an option on amd64 as well.
This commit is contained in:
Damien Elmes 2021-12-06 22:18:53 +10:00
parent d9c8addbc1
commit 0de24122ad
8 changed files with 241 additions and 40 deletions

55
qt/aqt/_macos_helper.py Normal file
View file

@ -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)

View file

@ -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)

View file

@ -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()

View file

@ -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__"],

View file

@ -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 = "<group>"; };
137892AF275DAE22009D0B6E /* record.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = record.swift; sourceTree = "<group>"; };
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 = "<group>";
@ -110,6 +113,7 @@
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
files = (
137892B0275DAE22009D0B6E /* record.swift in Sources */,
137892AC275D90FC009D0B6E /* theme.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;

View file

@ -0,0 +1,23 @@
<?xml version="1.0" encoding="UTF-8"?>
<Scheme
version = "1.3">
<BuildAction>
<BuildActionEntries>
<BuildActionEntry
buildForRunning = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "138B770E2746137F003A3E4F"
BuildableName = "libankihelper.dylib"
BlueprintName = "ankihelper"
ReferencedContainer = "container:ankihelper.xcodeproj">
</BuildableReference>
</BuildActionEntry>
</BuildActionEntries>
</BuildAction>
<LaunchAction
useCustomWorkingDirectory = "NO"
buildConfiguration = "Debug"
allowLocationSimulation = "YES">
</LaunchAction>
</Scheme>

117
qt/mac/record.swift Normal file
View file

@ -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<CChar>,
onError: @escaping @convention(c) (UnsafePointer<CChar>) -> 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)
}
}