From 643e8753428de6a93cf8e26c6b75746a5cedb88f Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Fri, 18 Dec 2020 16:52:00 +1000 Subject: [PATCH] add back pyaudio as an optional alternative --- qt/aqt/profiles.py | 15 ++++ qt/aqt/reviewer.py | 2 +- qt/aqt/sound.py | 204 ++++++++++++++++++++++++++++++++++++++------- qt/mypy.ini | 2 + 4 files changed, 192 insertions(+), 31 deletions(-) diff --git a/qt/aqt/profiles.py b/qt/aqt/profiles.py index 6fdd82dfa..9d2340f07 100644 --- a/qt/aqt/profiles.py +++ b/qt/aqt/profiles.py @@ -1,6 +1,7 @@ # Copyright: Ankitects Pty Ltd and contributors # License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html +import enum import io import locale import pickle @@ -8,6 +9,7 @@ import random import shutil import traceback import warnings +from enum import Enum from typing import Any, Dict, List, Optional 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 +class RecordingDriver(Enum): + QtRecorder = "qtrecorder" + PyAudio = "pyaudio" + + metaConf = dict( ver=0, updates=True, @@ -631,6 +638,14 @@ create table if not exists profiles def set_auto_sync_media_minutes(self, val: int) -> None: 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: diff --git a/qt/aqt/reviewer.py b/qt/aqt/reviewer.py index 65260c92c..a9a11ec7a 100644 --- a/qt/aqt/reviewer.py +++ b/qt/aqt/reviewer.py @@ -837,7 +837,7 @@ time = %(time)d; self._recordedAudio = path 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: if not self._recordedAudio: diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index af47d17e5..b6eccf92e 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -20,9 +20,11 @@ import aqt from anki import hooks from anki.cards import Card from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag +from anki.types import assert_exhaustive from anki.utils import isLin, isMac, isWin, namedtmp from aqt import gui_hooks from aqt.mpv import MPV, MPVBase, MPVCommandError +from aqt.profiles import RecordingDriver from aqt.qt import * from aqt.taskman import TaskManager 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) +# 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 ########################################################################## class RecordDialog(QDialog): - _recorder: QAudioRecorder + _recorder: Recorder def __init__( self, @@ -539,52 +692,43 @@ class RecordDialog(QDialog): saveGeom(self, "audioRecorder2") def _start_recording(self): - from PyQt5.QtMultimedia import QAudioRecorder - - # start recording - self._recorder = QAudioRecorder(self._parent) - self._output = namedtmp("rec.wav") - 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)) - self._recorder.setMuted(True) - self._recorder.record() + driver = self.mw.pm.recording_driver() + if driver is RecordingDriver.QtRecorder: + self._recorder = QtRecorder(namedtmp("rec.wav"), self._parent) + elif driver is RecordingDriver.PyAudio: + self._recorder = PyAudioRecorder(self.mw, namedtmp("rec.wav")) + else: + assert_exhaustive(driver) + self._recorder.start(self._start_timer) + def _start_timer(self): self._timer = t = QTimer(self._parent) t.timeout.connect(self._on_timer) # type: ignore t.setSingleShot(False) t.start(100) def _on_timer(self): + self._recorder.on_timer() duration = self._recorder.duration() - # disable mute after recording starts to avoid clicks/pops - 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)) - ) + self.label.setText(tr(TR.MEDIA_RECORDINGTIME, secs="%0.1f" % duration)) def accept(self): + self._timer.stop() + try: - self._recorder.stop() self._save_diag() + self._recorder.stop(lambda out: self._on_success(out)) finally: QDialog.accept(self) - self._on_success(self._output) - def reject(self): + self._timer.stop() + + def cleanup(out: str): + os.unlink(out) + try: - self._recorder.stop() - os.unlink(self._output) + self._recorder.stop(cleanup) finally: QDialog.reject(self) diff --git a/qt/mypy.ini b/qt/mypy.ini index 8481aaddd..0c318bf48 100644 --- a/qt/mypy.ini +++ b/qt/mypy.ini @@ -12,6 +12,8 @@ strict_equality = true [mypy-aqt.mpv] ignore_errors=true +[mypy-pyaudio] +ignore_missing_imports = True [mypy-win32file] ignore_missing_imports = True [mypy-win32pipe]