mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
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:
parent
d9c8addbc1
commit
0de24122ad
8 changed files with 241 additions and 40 deletions
55
qt/aqt/_macos_helper.py
Normal file
55
qt/aqt/_macos_helper.py
Normal 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)
|
|
@ -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)
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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__"],
|
||||
|
|
|
@ -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;
|
||||
|
|
Binary file not shown.
|
@ -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
117
qt/mac/record.swift
Normal 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)
|
||||
}
|
||||
}
|
Loading…
Reference in a new issue