add back pyaudio as an optional alternative

This commit is contained in:
Damien Elmes 2020-12-18 16:52:00 +10:00
parent af92bb5e93
commit 643e875342
4 changed files with 192 additions and 31 deletions

View File

@ -1,6 +1,7 @@
# Copyright: Ankitects Pty Ltd and contributors # Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import enum
import io import io
import locale import locale
import pickle import pickle
@ -8,6 +9,7 @@ import random
import shutil import shutil
import traceback import traceback
import warnings import warnings
from enum import Enum
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from send2trash import send2trash from send2trash import send2trash
@ -30,6 +32,11 @@ from aqt.utils import TR, locale_dir, showWarning, tr
# - Saves in sqlite rather than a flat file so the config can't be corrupted # - Saves in sqlite rather than a flat file so the config can't be corrupted
class RecordingDriver(Enum):
QtRecorder = "qtrecorder"
PyAudio = "pyaudio"
metaConf = dict( metaConf = dict(
ver=0, ver=0,
updates=True, updates=True,
@ -631,6 +638,14 @@ create table if not exists profiles
def set_auto_sync_media_minutes(self, val: int) -> None: def set_auto_sync_media_minutes(self, val: int) -> None:
self.profile["autoSyncMediaMinutes"] = val self.profile["autoSyncMediaMinutes"] = val
def recording_driver(self) -> RecordingDriver:
if driver := self.profile.get("recordingDriver"):
return driver
return RecordingDriver.QtRecorder
def set_recording_driver(self, driver: RecordingDriver):
self.profile["recordingDriver"] = driver.value()
###################################################################### ######################################################################
def apply_profile_options(self) -> None: def apply_profile_options(self) -> None:

View File

@ -837,7 +837,7 @@ time = %(time)d;
self._recordedAudio = path self._recordedAudio = path
self.onReplayRecorded() self.onReplayRecorded()
record_audio(self.mw, self.mw.taskman, False, after_record) record_audio(self.mw, self.mw, False, after_record)
def onReplayRecorded(self) -> None: def onReplayRecorded(self) -> None:
if not self._recordedAudio: if not self._recordedAudio:

View File

@ -20,9 +20,11 @@ import aqt
from anki import hooks from anki import hooks
from anki.cards import Card from anki.cards import Card
from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag
from anki.types import assert_exhaustive
from anki.utils import isLin, isMac, isWin, namedtmp from anki.utils import isLin, isMac, isWin, namedtmp
from aqt import gui_hooks from aqt import gui_hooks
from aqt.mpv import MPV, MPVBase, MPVCommandError from aqt.mpv import MPV, MPVBase, MPVCommandError
from aqt.profiles import RecordingDriver
from aqt.qt import * from aqt.qt import *
from aqt.taskman import TaskManager from aqt.taskman import TaskManager
from aqt.utils import TR, restoreGeom, saveGeom, showWarning, startup_info, tr from aqt.utils import TR, restoreGeom, saveGeom, showWarning, startup_info, tr
@ -489,12 +491,163 @@ def encode_mp3(mw: aqt.AnkiQt, src_wav: str, on_done: Callable[[str], None]) ->
mw.taskman.run_in_background(lambda: _encode_mp3(src_wav, dst_mp3), _on_done) mw.taskman.run_in_background(lambda: _encode_mp3(src_wav, dst_mp3), _on_done)
# Recording interface
##########################################################################
class Recorder(ABC):
# seconds to wait before recording
STARTUP_DELAY = 0.3
def __init__(self, output_path: str):
self.output_path = output_path
def start(self, on_done: Callable[[], None]) -> None:
"Start recording, then call on_done() when started."
self._started_at = time.time()
on_done()
def stop(self, on_done: Callable[[str], None]):
"Stop recording, then call on_done() when finished."
on_done(self.output_path)
def duration(self) -> float:
"Seconds since recording started."
return time.time() - self._started_at
def on_timer(self):
"Will be called periodically."
pass
# Qt recording
##########################################################################
class QtRecorder(Recorder):
def __init__(self, output_path: str, parent: QWidget):
super().__init__(output_path)
from PyQt5.QtMultimedia import QAudioRecorder
self._recorder = QAudioRecorder(parent)
audio = self._recorder.audioSettings()
audio.setSampleRate(44100)
audio.setChannelCount(1)
self._recorder.setEncodingSettings(
audio,
self._recorder.videoSettings(),
"audio/x-wav",
)
self._recorder.setOutputLocation(QUrl.fromLocalFile(self.output_path))
self._recorder.setMuted(True)
def start(self, on_done: Callable[[], None]) -> None:
self._recorder.record()
super().start(on_done)
def stop(self, on_done: Callable[[str], None]):
self._recorder.stop()
super().stop(on_done)
def on_timer(self):
duration = self._recorder.duration()
if duration >= 300:
# disable mute after recording starts to avoid clicks/pops
if self._recorder.isMuted():
self._recorder.setMuted(False)
# PyAudio recording
##########################################################################
try:
import pyaudio
except:
pyaudio = None
PYAU_CHANNELS = 1
PYAU_INPUT_INDEX: Optional[int] = None
class PyAudioThreadedRecorder(threading.Thread):
def __init__(self, output_path: str, startup_delay: float) -> None:
threading.Thread.__init__(self)
self._output_path = output_path
self._startup_delay = startup_delay
self.finish = False
# though we're using pyaudio here, we rely on Qt to trigger
# the permission prompt on macOS
if isMac and qtminor > 12:
from PyQt5.QtMultimedia import QAudioDeviceInfo
QAudioDeviceInfo.defaultInputDevice()
def run(self) -> None:
chunk = 1024
p = pyaudio.PyAudio()
rate = int(p.get_default_input_device_info()["defaultSampleRate"])
PYAU_FORMAT = pyaudio.paInt16
stream = p.open(
format=PYAU_FORMAT,
channels=PYAU_CHANNELS,
rate=rate,
input=True,
input_device_index=PYAU_INPUT_INDEX,
frames_per_buffer=chunk,
)
# swallow the first 300ms to allow audio device to quiesce
wait = int(rate * self._startup_delay)
stream.read(wait, exception_on_overflow=False)
# read data in a loop until self.finish is set
data = b""
while not self.finish:
data += stream.read(chunk, exception_on_overflow=False)
# write out the wave file
stream.close()
p.terminate()
wf = wave.open(self._output_path, "wb")
wf.setnchannels(PYAU_CHANNELS)
wf.setsampwidth(p.get_sample_size(PYAU_FORMAT))
wf.setframerate(rate)
wf.writeframes(data)
wf.close()
class PyAudioRecorder(Recorder):
def __init__(self, mw: aqt.AnkiQt, output_path: str):
super().__init__(output_path)
self.mw = mw
def start(self, on_done: Callable[[], None]) -> None:
self.thread = PyAudioThreadedRecorder(self.output_path, self.STARTUP_DELAY)
self.thread.start()
super().start(on_done)
def stop(self, on_done: Callable[[str], None]) -> None:
# ensure at least a second captured
while self.duration() < 1:
time.sleep(0.1)
def func(fut):
Recorder.stop(self, on_done)
self.thread.finish = True
self.mw.taskman.run_in_background(self.thread.join, func)
# Recording dialog # Recording dialog
########################################################################## ##########################################################################
class RecordDialog(QDialog): class RecordDialog(QDialog):
_recorder: QAudioRecorder _recorder: Recorder
def __init__( def __init__(
self, self,
@ -539,52 +692,43 @@ class RecordDialog(QDialog):
saveGeom(self, "audioRecorder2") saveGeom(self, "audioRecorder2")
def _start_recording(self): def _start_recording(self):
from PyQt5.QtMultimedia import QAudioRecorder driver = self.mw.pm.recording_driver()
if driver is RecordingDriver.QtRecorder:
# start recording self._recorder = QtRecorder(namedtmp("rec.wav"), self._parent)
self._recorder = QAudioRecorder(self._parent) elif driver is RecordingDriver.PyAudio:
self._output = namedtmp("rec.wav") self._recorder = PyAudioRecorder(self.mw, namedtmp("rec.wav"))
audio = self._recorder.audioSettings() else:
audio.setSampleRate(44100) assert_exhaustive(driver)
audio.setChannelCount(1) self._recorder.start(self._start_timer)
self._recorder.setEncodingSettings(
audio,
self._recorder.videoSettings(),
"audio/x-wav",
)
self._recorder.setOutputLocation(QUrl.fromLocalFile(self._output))
self._recorder.setMuted(True)
self._recorder.record()
def _start_timer(self):
self._timer = t = QTimer(self._parent) self._timer = t = QTimer(self._parent)
t.timeout.connect(self._on_timer) # type: ignore t.timeout.connect(self._on_timer) # type: ignore
t.setSingleShot(False) t.setSingleShot(False)
t.start(100) t.start(100)
def _on_timer(self): def _on_timer(self):
self._recorder.on_timer()
duration = self._recorder.duration() duration = self._recorder.duration()
# disable mute after recording starts to avoid clicks/pops self.label.setText(tr(TR.MEDIA_RECORDINGTIME, secs="%0.1f" % duration))
if duration < 300:
return
if self._recorder.isMuted():
self._recorder.setMuted(False)
self.label.setText(
tr(TR.MEDIA_RECORDINGTIME, secs="%0.1f" % (duration / 1000.0))
)
def accept(self): def accept(self):
self._timer.stop()
try: try:
self._recorder.stop()
self._save_diag() self._save_diag()
self._recorder.stop(lambda out: self._on_success(out))
finally: finally:
QDialog.accept(self) QDialog.accept(self)
self._on_success(self._output)
def reject(self): def reject(self):
self._timer.stop()
def cleanup(out: str):
os.unlink(out)
try: try:
self._recorder.stop() self._recorder.stop(cleanup)
os.unlink(self._output)
finally: finally:
QDialog.reject(self) QDialog.reject(self)

View File

@ -12,6 +12,8 @@ strict_equality = true
[mypy-aqt.mpv] [mypy-aqt.mpv]
ignore_errors=true ignore_errors=true
[mypy-pyaudio]
ignore_missing_imports = True
[mypy-win32file] [mypy-win32file]
ignore_missing_imports = True ignore_missing_imports = True
[mypy-win32pipe] [mypy-win32pipe]