add back pyaudio as an optional alternative
This commit is contained in:
parent
af92bb5e93
commit
643e875342
@ -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:
|
||||||
|
@ -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:
|
||||||
|
204
qt/aqt/sound.py
204
qt/aqt/sound.py
@ -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)
|
||||||
|
|
||||||
|
@ -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]
|
||||||
|
Loading…
Reference in New Issue
Block a user