rework the audio player API and add a simpler player implementation

- the new API operates on AVTags so it can support TTS as well as
audio files
- added a simple "run for each file" implementation for mpv and mplayer.
- will need to test handling of unicode filenames on Windows
- the old mpv and mplayer code is currently not active
This commit is contained in:
Damien Elmes 2020-01-20 19:52:58 +10:00
parent 5084438a0b
commit 707ac587ec
3 changed files with 199 additions and 25 deletions

View file

@ -35,7 +35,6 @@ import anki
from anki import hooks from anki import hooks
from anki.models import NoteType from anki.models import NoteType
from anki.rsbackend import TemplateReplacementList from anki.rsbackend import TemplateReplacementList
from anki.sound import stripSounds
QAData = Tuple[ QAData = Tuple[
# Card ID this QA comes from. Corresponds to 'cid' column. # Card ID this QA comes from. Corresponds to 'cid' column.
@ -154,7 +153,7 @@ def apply_custom_filters(
else: else:
# do we need to inject in FrontSide? # do we need to inject in FrontSide?
if node.field_name == "FrontSide" and front_side is not None: if node.field_name == "FrontSide" and front_side is not None:
node.current_text = stripSounds(front_side) node.current_text = ctx.col().backend.strip_av_tags(front_side)
field_text = node.current_text field_text = node.current_text
for filter_name in node.filters: for filter_name in node.filters:

View file

@ -394,14 +394,7 @@ close the profile or restart Anki."""
########################################################################## ##########################################################################
def setupSound(self) -> None: def setupSound(self) -> None:
if isWin: aqt.sound.setup_audio(self.pm.base)
return
try:
aqt.sound.setupMPV()
except FileNotFoundError:
print("mpv not found, reverting to mplayer")
except aqt.mpv.MPVProcessError:
print("mpv too old, reverting to mplayer")
# Collection load/unload # Collection load/unload
########################################################################## ##########################################################################

View file

@ -2,38 +2,123 @@
# 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 atexit import atexit
import html
import os import os
import random import random
import subprocess import subprocess
import sys import sys
import threading import threading
import time import time
from typing import Any, Callable, Dict, List, Optional, Tuple import wave
from abc import ABC, abstractmethod
from typing import Any, Callable, Dict, List, Optional, Tuple, cast
import pyaudio import pyaudio
import anki
from anki.lang import _ from anki.lang import _
from anki.sound import allSounds from anki.sound import AVTag, SoundOrVideoTag
from anki.utils import isLin, isMac, isWin, tmpdir from anki.utils import isLin, isMac, isWin, tmpdir
from aqt import gui_hooks from aqt import gui_hooks
from aqt.mpv import MPV, MPVBase from aqt.mpv import MPV, MPVBase
from aqt.qt import * from aqt.qt import *
from aqt.utils import restoreGeom, saveGeom, showWarning 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)
# Shared utils
# Main playing interface
########################################################################## ##########################################################################
def playFromText(text) -> None: class AVPlayer:
for match in allSounds(text): players: List[Player] = []
# filename is html encoded interrupt_playing = True
match = html.unescape(match)
play(match)
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()
# Packaged commands # Packaged commands
########################################################################## ##########################################################################
@ -59,6 +144,62 @@ def _packagedCmd(cmd) -> Tuple[Any, Dict[str, str]]:
return cmd, env return cmd, env
# 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"]
########################################################################## ##########################################################################
processingSrc = "rec.wav" processingSrc = "rec.wav"
@ -445,8 +586,6 @@ Recorder = PyAudioRecorder
# Recording dialog # Recording dialog
########################################################################## ##########################################################################
_player = queueMplayer
_queueEraser = clearMplayerQueue
def getAudio(parent, encode=True): def getAudio(parent, encode=True):
"Record and return filename" "Record and return filename"
@ -484,14 +623,57 @@ def getAudio(parent, encode=True):
r.postprocess(encode) r.postprocess(encode)
return r.file() return r.file()
def play(path) -> None:
_player(path) # 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)
# Legacy audio interface
##########################################################################
# these will be removed in the future
def clearAudioQueue() -> None: def clearAudioQueue() -> None:
_queueEraser() 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
# imports that add-ons may be expecting
# fmt:off
from anki.sound import allSounds, stripSounds # isort:skip pylint: disable=unused-import
# fmt:on
# add everything from this module into anki.sound for backwards compat # add everything from this module into anki.sound for backwards compat
_exports = [i for i in locals().items() if not i[0].startswith("__")] _exports = [i for i in locals().items() if not i[0].startswith("__")]