mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 22:12:21 -04:00
spawn a new slave mode mplayer for each file
This solves a few problems: - We no longer need to write temporary files to disk, as files passed on the command line with non-Latin text can be read by mplayer. - We no longer need to deal with mplayer processes left around in the background that have failed to terminate. - We don't need to deal with the added complexity that comes with polling mplayer's status output to determine when the file has finished playing. Also add seek_relative(), toggle_pause() and shutdown() as optional methods on AVPlayer.
This commit is contained in:
parent
01f3fd06d6
commit
d4d16d35a8
2 changed files with 72 additions and 190 deletions
|
@ -388,7 +388,6 @@ close the profile or restart Anki."""
|
||||||
def cleanupAndExit(self) -> None:
|
def cleanupAndExit(self) -> None:
|
||||||
self.errorHandler.unload()
|
self.errorHandler.unload()
|
||||||
self.mediaServer.shutdown()
|
self.mediaServer.shutdown()
|
||||||
aqt.sound.cleanupMPV()
|
|
||||||
self.app.exit(0)
|
self.app.exit(0)
|
||||||
|
|
||||||
# Sound/video
|
# Sound/video
|
||||||
|
|
261
qt/aqt/sound.py
261
qt/aqt/sound.py
|
@ -3,7 +3,6 @@
|
||||||
|
|
||||||
import atexit
|
import atexit
|
||||||
import os
|
import os
|
||||||
import random
|
|
||||||
import subprocess
|
import subprocess
|
||||||
import sys
|
import sys
|
||||||
import threading
|
import threading
|
||||||
|
@ -19,7 +18,7 @@ import anki
|
||||||
import aqt
|
import aqt
|
||||||
from anki.lang import _
|
from anki.lang import _
|
||||||
from anki.sound import AVTag, SoundOrVideoTag
|
from anki.sound import AVTag, SoundOrVideoTag
|
||||||
from anki.utils import isLin, isMac, isWin, tmpdir
|
from anki.utils import isLin, isMac, isWin
|
||||||
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 *
|
||||||
|
@ -46,6 +45,15 @@ class Player(ABC):
|
||||||
|
|
||||||
If implemented, the player must not call on_done() when the audio is stopped."""
|
If implemented, the player must not call on_done() when the audio is stopped."""
|
||||||
|
|
||||||
|
def seek_relative(self, secs: int) -> None:
|
||||||
|
"Jump forward or back by secs. Optional."
|
||||||
|
|
||||||
|
def toggle_pause(self) -> None:
|
||||||
|
"Optional."
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
"Do any cleanup required at program termination. Optional."
|
||||||
|
|
||||||
|
|
||||||
class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method
|
class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method
|
||||||
def can_play(self, tag: AVTag) -> bool:
|
def can_play(self, tag: AVTag) -> bool:
|
||||||
|
@ -64,7 +72,7 @@ class AVPlayer:
|
||||||
|
|
||||||
def __init__(self):
|
def __init__(self):
|
||||||
self._enqueued: List[AVTag] = []
|
self._enqueued: List[AVTag] = []
|
||||||
self._current_player: Optional[Player] = None
|
self.current_player: Optional[Player] = None
|
||||||
|
|
||||||
def play_tags(self, tags: List[AVTag]) -> None:
|
def play_tags(self, tags: List[AVTag]) -> None:
|
||||||
"""Clear the existing queue, then start playing provided tags."""
|
"""Clear the existing queue, then start playing provided tags."""
|
||||||
|
@ -93,10 +101,23 @@ class AVPlayer:
|
||||||
def play_file(self, filename: str) -> None:
|
def play_file(self, filename: str) -> None:
|
||||||
self.play_tags([SoundOrVideoTag(filename=filename)])
|
self.play_tags([SoundOrVideoTag(filename=filename)])
|
||||||
|
|
||||||
|
def toggle_pause(self):
|
||||||
|
if self.current_player:
|
||||||
|
self.current_player.toggle_pause()
|
||||||
|
|
||||||
|
def seek_relative(self, secs: int) -> None:
|
||||||
|
if self.current_player:
|
||||||
|
self.current_player.seek_relative(secs)
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.stop_and_clear_queue()
|
||||||
|
for player in self.players:
|
||||||
|
player.shutdown()
|
||||||
|
|
||||||
def _stop_if_playing(self) -> None:
|
def _stop_if_playing(self) -> None:
|
||||||
if self._current_player:
|
if self.current_player:
|
||||||
self._current_player.stop()
|
self.current_player.stop()
|
||||||
self._current_player = None
|
self.current_player = None
|
||||||
|
|
||||||
def _pop_next(self) -> Optional[AVTag]:
|
def _pop_next(self) -> Optional[AVTag]:
|
||||||
if not self._enqueued:
|
if not self._enqueued:
|
||||||
|
@ -104,12 +125,12 @@ class AVPlayer:
|
||||||
return self._enqueued.pop(0)
|
return self._enqueued.pop(0)
|
||||||
|
|
||||||
def _on_play_finished(self) -> None:
|
def _on_play_finished(self) -> None:
|
||||||
self._current_player = None
|
self.current_player = None
|
||||||
gui_hooks.av_player_did_play()
|
gui_hooks.av_player_did_play()
|
||||||
self._play_next_if_idle()
|
self._play_next_if_idle()
|
||||||
|
|
||||||
def _play_next_if_idle(self) -> None:
|
def _play_next_if_idle(self) -> None:
|
||||||
if self._current_player:
|
if self.current_player:
|
||||||
return
|
return
|
||||||
|
|
||||||
next = self._pop_next()
|
next = self._pop_next()
|
||||||
|
@ -119,7 +140,7 @@ class AVPlayer:
|
||||||
def _play(self, tag: AVTag) -> None:
|
def _play(self, tag: AVTag) -> None:
|
||||||
for player in self.players:
|
for player in self.players:
|
||||||
if player.can_play(tag):
|
if player.can_play(tag):
|
||||||
self._current_player = player
|
self.current_player = player
|
||||||
gui_hooks.av_player_will_play(tag)
|
gui_hooks.av_player_will_play(tag)
|
||||||
player.play(tag, self._on_play_finished)
|
player.play(tag, self._on_play_finished)
|
||||||
return
|
return
|
||||||
|
@ -289,212 +310,74 @@ class MpvManager(MPV, SoundOrVideoPlayer):
|
||||||
def stop(self) -> None:
|
def stop(self) -> None:
|
||||||
self.command("stop")
|
self.command("stop")
|
||||||
|
|
||||||
def togglePause(self) -> None:
|
def toggle_pause(self) -> None:
|
||||||
self.set_property("pause", not self.get_property("pause"))
|
self.set_property("pause", not self.get_property("pause"))
|
||||||
|
|
||||||
def seekRelative(self, secs) -> None:
|
def seek_relative(self, secs) -> None:
|
||||||
self.command("seek", secs, "relative")
|
self.command("seek", secs, "relative")
|
||||||
|
|
||||||
def on_idle(self) -> None:
|
def on_idle(self) -> None:
|
||||||
if self._on_done:
|
if self._on_done:
|
||||||
self._on_done()
|
self._on_done()
|
||||||
|
|
||||||
|
def shutdown(self) -> None:
|
||||||
|
self.close()
|
||||||
|
|
||||||
# Legacy, not used
|
# Legacy, not used
|
||||||
##################################################
|
##################################################
|
||||||
|
|
||||||
|
togglePause = toggle_pause
|
||||||
|
seekRelative = seek_relative
|
||||||
|
|
||||||
def queueFile(self, file: str) -> None:
|
def queueFile(self, file: str) -> None:
|
||||||
path = os.path.join(os.getcwd(), file)
|
return
|
||||||
self.command("loadfile", path, "append-play")
|
|
||||||
|
|
||||||
def clearQueue(self) -> None:
|
def clearQueue(self) -> None:
|
||||||
self.command("stop")
|
return
|
||||||
|
|
||||||
|
|
||||||
def cleanupMPV() -> None:
|
|
||||||
global mpvManager
|
|
||||||
if mpvManager:
|
|
||||||
mpvManager.close()
|
|
||||||
mpvManager = None
|
|
||||||
|
|
||||||
|
|
||||||
# Mplayer in slave mode
|
# 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
|
|
||||||
import psutil # pytype: disable=import-error
|
|
||||||
|
|
||||||
exeDir = os.path.dirname(os.path.abspath(sys.argv[0]))
|
class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer):
|
||||||
|
def __init__(self, taskman: TaskManager):
|
||||||
|
super().__init__(taskman)
|
||||||
|
|
||||||
for proc in psutil.process_iter():
|
self._process: Optional[subprocess.Popen] = None
|
||||||
|
|
||||||
|
self.args.append("-slave")
|
||||||
|
|
||||||
|
def _play(self, filename: str) -> None:
|
||||||
|
self._process = subprocess.Popen(
|
||||||
|
self.args + [filename], env=self.env, stdin=subprocess.PIPE
|
||||||
|
)
|
||||||
|
while True:
|
||||||
try:
|
try:
|
||||||
info = proc.as_dict(attrs=["pid", "name", "exe"])
|
self._process.wait(0.1)
|
||||||
if not info["exe"] or info["name"] != "mplayer.exe":
|
if self._process.returncode != 0:
|
||||||
continue
|
print(f"player got return code: {self._process.returncode}")
|
||||||
|
|
||||||
# not anki's bundled mplayer
|
|
||||||
if os.path.dirname(info["exe"]) != exeDir:
|
|
||||||
continue
|
|
||||||
|
|
||||||
print("terminating old mplayer process...")
|
|
||||||
proc.kill()
|
|
||||||
except:
|
|
||||||
print("error iterating mplayer processes")
|
|
||||||
|
|
||||||
|
|
||||||
mplayerCmd = ["mplayer", "-really-quiet", "-noautosub"]
|
|
||||||
if isWin:
|
|
||||||
mplayerCmd += ["-ao", "win32"]
|
|
||||||
|
|
||||||
cleanupOldMplayerProcesses()
|
|
||||||
|
|
||||||
mplayerQueue: List[str] = []
|
|
||||||
mplayerEvt = threading.Event()
|
|
||||||
mplayerClear = False
|
|
||||||
|
|
||||||
|
|
||||||
class MplayerMonitor(threading.Thread):
|
|
||||||
|
|
||||||
mplayer: Optional[subprocess.Popen] = None
|
|
||||||
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:
|
|
||||||
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
|
|
||||||
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
|
|
||||||
|
|
||||||
self.deadPlayers = [pl for pl in self.deadPlayers if clean(pl)]
|
|
||||||
|
|
||||||
def kill(self) -> None:
|
|
||||||
if not self.mplayer:
|
|
||||||
return
|
return
|
||||||
try:
|
except subprocess.TimeoutExpired:
|
||||||
self.mplayer.stdin.write(b"quit\n")
|
|
||||||
self.mplayer.stdin.flush()
|
|
||||||
self.deadPlayers.append(self.mplayer)
|
|
||||||
except:
|
|
||||||
pass
|
pass
|
||||||
self.mplayer = None
|
if self._terminate_flag:
|
||||||
|
self._process.terminate()
|
||||||
|
self._terminate_flag = False
|
||||||
|
raise PlayerInterrupted()
|
||||||
|
|
||||||
def startProcess(self) -> subprocess.Popen:
|
def command(self, text: str) -> None:
|
||||||
try:
|
"""Send a command over the slave interface.
|
||||||
cmd = mplayerCmd + ["-slave", "-idle"]
|
|
||||||
cmd, env = _packagedCmd(cmd)
|
|
||||||
return subprocess.Popen(
|
|
||||||
cmd,
|
|
||||||
startupinfo=si,
|
|
||||||
stdin=subprocess.PIPE,
|
|
||||||
stdout=subprocess.DEVNULL,
|
|
||||||
stderr=subprocess.DEVNULL,
|
|
||||||
env=env,
|
|
||||||
)
|
|
||||||
except OSError:
|
|
||||||
mplayerEvt.clear()
|
|
||||||
raise Exception("Did you install mplayer?")
|
|
||||||
|
|
||||||
|
The trailing newline is automatically added."""
|
||||||
|
self._process.stdin.write(text.encode("utf8") + b"\n")
|
||||||
|
self._process.stdin.flush()
|
||||||
|
|
||||||
mplayerManager: Optional[MplayerMonitor] = None
|
def seek_relative(self, secs: int) -> None:
|
||||||
|
self.command(f"seek {secs} 0")
|
||||||
|
|
||||||
|
def toggle_pause(self):
|
||||||
|
self.command("pause")
|
||||||
|
|
||||||
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()
|
|
||||||
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()
|
|
||||||
|
|
||||||
|
|
||||||
def clearMplayerQueue() -> None:
|
|
||||||
global mplayerClear, mplayerQueue
|
|
||||||
mplayerQueue = []
|
|
||||||
mplayerClear = True
|
|
||||||
mplayerEvt.set()
|
|
||||||
|
|
||||||
|
|
||||||
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)
|
|
||||||
|
|
||||||
|
|
||||||
def stopMplayer(*args) -> None:
|
|
||||||
if not mplayerManager:
|
|
||||||
return
|
|
||||||
mplayerManager.kill()
|
|
||||||
if isWin:
|
|
||||||
cleanupOldMplayerProcesses()
|
|
||||||
|
|
||||||
|
|
||||||
gui_hooks.profile_will_close.append(stopMplayer)
|
|
||||||
|
|
||||||
# PyAudio recording
|
# PyAudio recording
|
||||||
##########################################################################
|
##########################################################################
|
||||||
|
@ -692,10 +575,8 @@ def setup_audio(taskman: TaskManager, base_folder: str) -> None:
|
||||||
|
|
||||||
if mpvManager is not None:
|
if mpvManager is not None:
|
||||||
av_player.players.append(mpvManager)
|
av_player.players.append(mpvManager)
|
||||||
atexit.register(cleanupMPV)
|
|
||||||
else:
|
else:
|
||||||
# fall back on mplayer
|
mplayer = SimpleMplayerSlaveModePlayer(taskman)
|
||||||
mplayer = SimpleMplayerPlayer(taskman)
|
|
||||||
av_player.players.append(mplayer)
|
av_player.players.append(mplayer)
|
||||||
|
|
||||||
# currently unused
|
# currently unused
|
||||||
|
@ -707,3 +588,5 @@ def setup_audio(taskman: TaskManager, base_folder: str) -> None:
|
||||||
from aqt.tts import MacTTSPlayer
|
from aqt.tts import MacTTSPlayer
|
||||||
|
|
||||||
av_player.players.append(MacTTSPlayer(taskman))
|
av_player.players.append(MacTTSPlayer(taskman))
|
||||||
|
|
||||||
|
atexit.register(av_player.shutdown)
|
||||||
|
|
Loading…
Reference in a new issue