anki/qt/aqt/sound.py

682 lines
19 KiB
Python
Raw Normal View History

2019-02-05 04:59:03 +01:00
# Copyright: Ankitects Pty Ltd and contributors
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
2019-12-20 10:19:03 +01:00
import atexit
import os
import random
import subprocess
import sys
import threading
import time
import wave
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Tuple, cast
import pyaudio
import anki
from anki.lang import _
from anki.sound import AVTag, SoundOrVideoTag
2019-12-20 10:19:03 +01:00
from anki.utils import isLin, isMac, isWin, tmpdir
2020-01-13 05:38:05 +01:00
from aqt import gui_hooks
from aqt.mpv import MPV, MPVBase
from aqt.qt import *
from aqt.taskman import TaskManager
from aqt.utils import restoreGeom, saveGeom
# AV player protocol
##########################################################################
OnDoneCallback = Callable[[], None]
class Player(ABC):
@abstractmethod
def can_play(self, tag: AVTag) -> bool:
pass
@abstractmethod
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
pass
def stop(self) -> None:
"Optional."
class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method
def can_play(self, tag: AVTag) -> bool:
return isinstance(tag, SoundOrVideoTag)
# Main playing interface
##########################################################################
2019-12-23 01:34:10 +01:00
class AVPlayer:
players: List[Player] = []
interrupt_playing = True
def __init__(self):
self._enqueued: List[AVTag] = []
self._current_player: Optional[Player] = None
def play_tags(self, tags: List[AVTag]) -> None:
"""Clear the existing queue, then start playing provided tags."""
self._enqueued = tags
if self.interrupt_playing:
self._stop_if_playing()
self._play_next_if_idle()
def extend_and_play(self, tags: List[AVTag]) -> None:
"""Add extra tags to queue, without clearing it."""
self._enqueued.extend(tags)
self._play_next_if_idle()
def play_from_text(self, col: anki.storage._Collection, text: str) -> None:
tags = col.backend.get_av_tags(text)
self.play_tags(tags)
def extend_from_text(self, col: anki.storage._Collection, text: str) -> None:
tags = col.backend.get_av_tags(text)
self.extend_and_play(tags)
def stop_and_clear_queue(self) -> None:
self._enqueued = []
self._stop_if_playing()
def play_file(self, filename: str) -> None:
self.play_tags([SoundOrVideoTag(filename=filename)])
def _stop_if_playing(self) -> None:
if self._current_player:
self._current_player.stop()
self._current_player = None
def _pop_next(self) -> Optional[AVTag]:
if not self._enqueued:
return None
return self._enqueued.pop(0)
def _on_play_finished(self) -> None:
self._current_player = None
self._play_next_if_idle()
def _play_next_if_idle(self) -> None:
if self._current_player:
return
next = self._pop_next()
if next is not None:
self._play(next)
def _play(self, tag: AVTag) -> None:
for player in self.players:
if player.can_play(tag):
self._current_player = player
player.play(tag, self._on_play_finished)
return
print("no players found for", tag)
av_player = AVPlayer()
2019-12-23 01:34:10 +01:00
# Packaged commands
##########################################################################
# return modified command array that points to bundled command, and return
# required environment
def _packagedCmd(cmd) -> Tuple[Any, Dict[str, str]]:
cmd = cmd[:]
env = os.environ.copy()
if "LD_LIBRARY_PATH" in env:
2019-12-23 01:34:10 +01:00
del env["LD_LIBRARY_PATH"]
if isMac:
dir = os.path.dirname(os.path.abspath(__file__))
2017-01-09 03:52:52 +01:00
exeDir = os.path.abspath(dir + "/../../Resources/audio")
else:
exeDir = os.path.dirname(os.path.abspath(sys.argv[0]))
if isWin and not cmd[0].endswith(".exe"):
cmd[0] += ".exe"
path = os.path.join(exeDir, cmd[0])
if not os.path.exists(path):
return cmd, env
cmd[0] = path
return cmd, env
2019-12-23 01:34:10 +01:00
# Simple player implementations
##########################################################################
class SimpleProcessPlayer(SoundOrVideoPlayer):
"A player that invokes a new process for each file to play."
_on_done: Optional[OnDoneCallback]
_taskman = TaskManager()
_terminate_flag = False
args: List[str] = []
env: Optional[Dict[str, str]] = None
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
stag = cast(SoundOrVideoTag, tag)
self._terminate_flag = False
self._taskman.run(lambda: self._play(stag.filename), lambda res: on_done())
def stop(self):
self._terminate_flag = True
def _play(self, filename: str) -> None:
process = subprocess.Popen(self.args + [filename], env=self.env)
while True:
try:
process.wait(0.1)
if process.returncode != 0:
raise Exception(f"player got return code: {process.returncode}")
return
except subprocess.TimeoutExpired:
pass
if self._terminate_flag:
process.terminate()
class SimpleMpvPlayer(SimpleProcessPlayer):
args, env = _packagedCmd(
[
"mpv",
"--no-terminal",
"--force-window=no",
"--ontop",
"--audio-display=no",
"--keep-open=no",
"--input-media-keys=no",
"--no-config",
]
)
class SimpleMplayerPlayer(SimpleProcessPlayer):
args, env = _packagedCmd(["mplayer", "-really-quiet", "-noautosub"])
if isWin:
args += ["-ao", "win32"]
2020-01-20 12:03:22 +01:00
# Platform hacks
##########################################################################
# don't show box on windows
2019-12-20 06:07:40 +01:00
si: Optional[Any]
2019-12-15 23:17:28 +01:00
if sys.platform == "win32":
2019-12-23 01:34:10 +01:00
si = subprocess.STARTUPINFO() # pytype: disable=module-attr
try:
2019-12-23 01:34:10 +01:00
si.dwFlags |= subprocess.STARTF_USESHOWWINDOW # pytype: disable=module-attr
except:
# pylint: disable=no-member
# python2.7+
2019-12-23 01:34:10 +01:00
si.dwFlags |= (
subprocess._subprocess.STARTF_USESHOWWINDOW
) # pytype: disable=module-attr
else:
si = None
2019-12-23 01:34:10 +01:00
2020-01-20 12:03:22 +01:00
# osx throws interrupted system call errors frequently
def retryWait(proc) -> Any:
while 1:
try:
return proc.wait()
except OSError:
continue
2019-12-23 01:34:10 +01:00
2017-09-30 09:24:56 +02:00
# MPV
##########################################################################
2017-09-30 09:24:56 +02:00
_player: Optional[Callable[[Any], Any]]
_queueEraser: Optional[Callable[[], Any]]
2019-12-20 06:07:40 +01:00
mpvManager: Optional["MpvManager"] = None
mpvPath, mpvEnv = _packagedCmd(["mpv"])
2019-12-23 01:34:10 +01:00
2017-09-30 09:24:56 +02:00
class MpvManager(MPV):
executable = mpvPath[0]
popenEnv = mpvEnv
if not isLin:
default_argv = MPVBase.default_argv + [
"--input-media-keys=no",
]
def __init__(self) -> None:
2017-09-30 11:33:09 +02:00
super().__init__(window_id=None, debug=False)
2017-09-30 09:24:56 +02:00
2020-01-13 05:38:05 +01:00
def queueFile(self, file: str) -> None:
2020-01-15 07:53:24 +01:00
gui_hooks.mpv_will_play(file)
2017-09-30 09:24:56 +02:00
path = os.path.join(os.getcwd(), file)
self.command("loadfile", path, "append-play")
2017-09-30 09:24:56 +02:00
def clearQueue(self) -> None:
2017-09-30 09:24:56 +02:00
self.command("stop")
def togglePause(self) -> None:
2017-09-30 09:24:56 +02:00
self.set_property("pause", not self.get_property("pause"))
def seekRelative(self, secs) -> None:
2017-09-30 09:24:56 +02:00
self.command("seek", secs, "relative")
def on_idle(self) -> None:
2020-01-15 07:53:24 +01:00
gui_hooks.mpv_did_idle()
2019-12-23 01:34:10 +01:00
def setMpvConfigBase(base) -> None:
mpvConfPath = os.path.join(base, "mpv.conf")
MpvManager.default_argv += [
"--no-config",
2019-12-23 01:34:10 +01:00
"--include=" + mpvConfPath,
]
2019-12-23 01:34:10 +01:00
def setupMPV() -> None:
2017-09-30 09:24:56 +02:00
global mpvManager, _player, _queueEraser
mpvManager = MpvManager()
_player = mpvManager.queueFile
_queueEraser = mpvManager.clearQueue
2019-04-29 06:36:51 +02:00
atexit.register(cleanupMPV)
2017-09-30 09:24:56 +02:00
2019-12-23 01:34:10 +01:00
def cleanupMPV() -> None:
2017-09-30 09:24:56 +02:00
global mpvManager, _player, _queueEraser
if mpvManager:
mpvManager.close()
mpvManager = None
_player = None
_queueEraser = None
2019-12-23 01:34:10 +01:00
# Mplayer in slave mode
##########################################################################
# if anki crashes, an old mplayer instance may be left lying around,
# which prevents renaming or deleting the profile
def cleanupOldMplayerProcesses() -> None:
# pylint: disable=import-error
2019-12-23 01:34:10 +01:00
import psutil # pytype: disable=import-error
exeDir = os.path.dirname(os.path.abspath(sys.argv[0]))
2019-02-27 02:51:28 +01:00
for proc in psutil.process_iter():
try:
2019-12-23 01:34:10 +01:00
info = proc.as_dict(attrs=["pid", "name", "exe"])
if not info["exe"] or info["name"] != "mplayer.exe":
2019-02-27 02:51:28 +01:00
continue
# not anki's bundled mplayer
2019-12-23 01:34:10 +01:00
if os.path.dirname(info["exe"]) != exeDir:
2019-02-27 02:51:28 +01:00
continue
print("terminating old mplayer process...")
proc.kill()
except:
print("error iterating mplayer processes")
2019-12-23 01:34:10 +01:00
2017-09-30 09:24:56 +02:00
mplayerCmd = ["mplayer", "-really-quiet", "-noautosub"]
if isWin:
mplayerCmd += ["-ao", "win32"]
2017-09-30 09:24:56 +02:00
cleanupOldMplayerProcesses()
2019-12-15 23:17:28 +01:00
mplayerQueue: List[str] = []
mplayerEvt = threading.Event()
mplayerClear = False
2019-12-23 01:34:10 +01:00
class MplayerMonitor(threading.Thread):
2019-12-20 06:07:40 +01:00
mplayer: Optional[subprocess.Popen] = None
2019-12-16 08:54:04 +01:00
deadPlayers: List[subprocess.Popen] = []
def run(self) -> None:
global mplayerClear
self.mplayer = None
self.deadPlayers = []
while 1:
mplayerEvt.wait()
mplayerEvt.clear()
# clearing queue?
if mplayerClear and self.mplayer:
try:
self.mplayer.stdin.write(b"stop\n")
self.mplayer.stdin.flush()
except:
# mplayer quit by user (likely video)
self.deadPlayers.append(self.mplayer)
self.mplayer = None
# loop through files to play
while mplayerQueue:
# ensure started
if not self.mplayer:
2019-12-16 08:54:04 +01:00
self.mplayer = self.startProcess()
# pop a file
try:
item = mplayerQueue.pop(0)
except IndexError:
# queue was cleared by main thread
continue
if mplayerClear:
mplayerClear = False
extra = b""
else:
extra = b" 1"
cmd = b'loadfile "%s"%s\n' % (item.encode("utf8"), extra)
try:
self.mplayer.stdin.write(cmd)
self.mplayer.stdin.flush()
except:
# mplayer has quit and needs restarting
self.deadPlayers.append(self.mplayer)
self.mplayer = None
2019-12-16 08:54:04 +01:00
self.mplayer = self.startProcess()
self.mplayer.stdin.write(cmd)
self.mplayer.stdin.flush()
# if we feed mplayer too fast it loses files
time.sleep(1)
# wait() on finished processes. we don't want to block on the
# wait, so we keep trying each time we're reactivated
def clean(pl):
if pl.poll() is not None:
pl.wait()
return False
else:
return True
2019-12-23 01:34:10 +01:00
self.deadPlayers = [pl for pl in self.deadPlayers if clean(pl)]
def kill(self) -> None:
if not self.mplayer:
return
try:
self.mplayer.stdin.write(b"quit\n")
self.mplayer.stdin.flush()
self.deadPlayers.append(self.mplayer)
except:
pass
self.mplayer = None
def startProcess(self) -> subprocess.Popen:
try:
cmd = mplayerCmd + ["-slave", "-idle"]
cmd, env = _packagedCmd(cmd)
2019-12-16 08:54:04 +01:00
return subprocess.Popen(
2019-12-23 01:34:10 +01:00
cmd,
startupinfo=si,
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
env=env,
)
except OSError:
mplayerEvt.clear()
raise Exception("Did you install mplayer?")
2019-12-23 01:34:10 +01:00
2019-12-20 06:07:40 +01:00
mplayerManager: Optional[MplayerMonitor] = None
2019-12-23 01:34:10 +01:00
def queueMplayer(path) -> None:
ensureMplayerThreads()
if isWin and os.path.exists(path):
# mplayer on windows doesn't like the encoding, so we create a
# temporary file instead. oddly, foreign characters in the dirname
# don't seem to matter.
dir = tmpdir()
2019-12-23 01:34:10 +01:00
name = os.path.join(
dir, "audio%s%s" % (random.randrange(0, 1000000), os.path.splitext(path)[1])
)
f = open(name, "wb")
f.write(open(path, "rb").read())
f.close()
# it wants unix paths, too!
path = name.replace("\\", "/")
mplayerQueue.append(path)
mplayerEvt.set()
2019-12-23 01:34:10 +01:00
def clearMplayerQueue() -> None:
global mplayerClear, mplayerQueue
mplayerQueue = []
mplayerClear = True
mplayerEvt.set()
2019-12-23 01:34:10 +01:00
def ensureMplayerThreads() -> None:
global mplayerManager
if not mplayerManager:
mplayerManager = MplayerMonitor()
mplayerManager.daemon = True
mplayerManager.start()
# ensure the tmpdir() exit handler is registered first so it runs
# after the mplayer exit
tmpdir()
# clean up mplayer on exit
atexit.register(stopMplayer)
2019-12-23 01:34:10 +01:00
def stopMplayer(*args) -> None:
if not mplayerManager:
return
mplayerManager.kill()
if isWin:
cleanupOldMplayerProcesses()
2019-12-23 01:34:10 +01:00
2020-01-15 07:53:24 +01:00
gui_hooks.profile_will_close.append(stopMplayer)
# PyAudio recording
##########################################################################
PYAU_FORMAT = pyaudio.paInt16
PYAU_CHANNELS = 1
PYAU_INPUT_INDEX: Optional[int] = None
2020-01-20 12:03:22 +01:00
processingSrc = "rec.wav"
processingDst = "rec.mp3"
recFiles: List[str] = []
processingChain: List[List[str]] = [
["lame", processingSrc, processingDst, "--noreplaygain", "--quiet"],
]
2019-12-23 01:34:10 +01:00
class _Recorder:
def postprocess(self, encode=True) -> None:
self.encode = encode
for c in processingChain:
2019-12-23 01:34:10 +01:00
# print c
if not self.encode and c[0] == "lame":
continue
try:
cmd, env = _packagedCmd(c)
ret = retryWait(subprocess.Popen(cmd, startupinfo=si, env=env))
except:
ret = True
finally:
2019-07-14 03:19:29 +02:00
self.cleanup()
if ret:
2019-12-23 01:34:10 +01:00
raise Exception(_("Error running %s") % " ".join(cmd))
def cleanup(self) -> None:
2019-07-14 03:19:29 +02:00
if os.path.exists(processingSrc):
os.unlink(processingSrc)
2019-12-23 01:34:10 +01:00
class PyAudioThreadedRecorder(threading.Thread):
def __init__(self, startupDelay) -> None:
threading.Thread.__init__(self)
self.startupDelay = startupDelay
self.finish = False
def run(self) -> Any:
chunk = 1024
2016-06-23 04:04:48 +02:00
p = pyaudio.PyAudio()
2019-12-23 01:34:10 +01:00
rate = int(p.get_default_input_device_info()["defaultSampleRate"])
wait = int(rate * self.startupDelay)
2019-12-23 01:34:10 +01:00
stream = p.open(
format=PYAU_FORMAT,
channels=PYAU_CHANNELS,
rate=rate,
input=True,
input_device_index=PYAU_INPUT_INDEX,
frames_per_buffer=chunk,
)
stream.read(wait)
2016-05-31 09:51:16 +02:00
data = b""
while not self.finish:
2018-12-22 04:41:35 +01:00
data += stream.read(chunk, exception_on_overflow=False)
stream.close()
p.terminate()
2019-12-23 01:34:10 +01:00
wf = wave.open(processingSrc, "wb")
wf.setnchannels(PYAU_CHANNELS)
wf.setsampwidth(p.get_sample_size(PYAU_FORMAT))
wf.setframerate(rate)
wf.writeframes(data)
wf.close()
2019-12-23 01:34:10 +01:00
class PyAudioRecorder(_Recorder):
# discard first 250ms which may have pops/cracks
startupDelay = 0.25
def __init__(self):
for t in recFiles + [processingSrc, processingDst]:
try:
os.unlink(t)
except OSError:
pass
self.encode = False
def start(self):
self.thread = PyAudioThreadedRecorder(startupDelay=self.startupDelay)
self.thread.start()
def stop(self):
self.thread.finish = True
self.thread.join()
def file(self):
if self.encode:
tgt = "rec%d.mp3" % time.time()
os.rename(processingDst, tgt)
return tgt
else:
return processingSrc
2019-12-23 01:34:10 +01:00
Recorder = PyAudioRecorder
2019-02-13 00:36:39 +01:00
# Recording dialog
##########################################################################
def getAudio(parent, encode=True):
"Record and return filename"
# record first
r = Recorder()
mb = QMessageBox(parent)
restoreGeom(mb, "audioRecorder")
mb.setWindowTitle("Anki")
mb.setIconPixmap(QPixmap(":/icons/media-record.png"))
but = QPushButton(_("Save"))
mb.addButton(but, QMessageBox.AcceptRole)
but.setDefault(True)
but = QPushButton(_("Cancel"))
mb.addButton(but, QMessageBox.RejectRole)
mb.setEscapeButton(but)
t = time.time()
r.start()
time.sleep(r.startupDelay)
QApplication.instance().processEvents()
while not mb.clickedButton():
txt = _("Recording...<br>Time: %0.1f")
mb.setText(txt % (time.time() - t))
mb.show()
QApplication.instance().processEvents()
if mb.clickedButton() == mb.escapeButton():
r.stop()
r.cleanup()
return
saveGeom(mb, "audioRecorder")
# ensure at least a second captured
while time.time() - t < 1:
time.sleep(0.1)
r.stop()
# process
r.postprocess(encode)
return r.file()
2019-12-23 01:34:10 +01:00
# Init defaults
##########################################################################
def setup_audio(base_folder: str) -> None:
# if isWin:
# return
# try:
# setupMPV()
# except FileNotFoundError:
# print("mpv not found, reverting to mplayer")
# except aqt.mpv.MPVProcessError:
# print("mpv too old, reverting to mplayer")
#
if isWin:
mplayer = SimpleMplayerPlayer()
av_player.players.append(mplayer)
else:
mpv = SimpleMpvPlayer()
mpv.args.append("--include=" + base_folder)
av_player.players.append(mpv)
if isMac:
from aqt.tts import MacTTSPlayer
av_player.players.append(MacTTSPlayer())
# Legacy audio interface
##########################################################################
# these will be removed in the future
2019-12-23 01:34:10 +01:00
def clearAudioQueue() -> None:
av_player.stop_and_clear_queue()
def play(filename: str) -> None:
av_player.play_file(filename)
def playFromText(text) -> None:
from aqt import mw
av_player.extend_from_text(mw.col, text)
_player = play
_queueEraser = clearAudioQueue
2019-12-23 01:34:10 +01:00
# add everything from this module into anki.sound for backwards compat
_exports = [i for i in locals().items() if not i[0].startswith("__")]
for (k, v) in _exports:
sys.modules["anki.sound"].__dict__[k] = v