2019-02-05 04:59:03 +01:00
|
|
|
# Copyright: Ankitects Pty Ltd and contributors
|
2012-12-21 08:51:59 +01:00
|
|
|
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
2020-12-16 10:09:45 +01:00
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
2019-12-20 10:19:03 +01:00
|
|
|
import atexit
|
|
|
|
import os
|
2020-12-20 03:10:23 +01:00
|
|
|
import platform
|
2020-01-21 05:47:03 +01:00
|
|
|
import re
|
2019-12-20 10:19:03 +01:00
|
|
|
import subprocess
|
|
|
|
import sys
|
|
|
|
import time
|
2020-01-20 10:52:58 +01:00
|
|
|
import wave
|
|
|
|
from abc import ABC, abstractmethod
|
2020-01-20 22:28:19 +01:00
|
|
|
from concurrent.futures import Future
|
2020-01-21 11:39:25 +01:00
|
|
|
from operator import itemgetter
|
2021-10-28 10:46:45 +02:00
|
|
|
from pathlib import Path
|
2021-10-08 07:55:38 +02:00
|
|
|
from typing import Any, Callable, cast
|
2019-12-19 04:02:45 +01:00
|
|
|
|
2021-06-02 04:14:20 +02:00
|
|
|
from markdown import markdown
|
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
import aqt
|
2020-11-09 10:45:14 +01:00
|
|
|
from anki import hooks
|
2020-01-24 02:06:11 +01:00
|
|
|
from anki.cards import Card
|
2020-01-24 06:48:40 +01:00
|
|
|
from anki.sound import AV_REF_RE, AVTag, SoundOrVideoTag
|
2020-12-16 10:09:45 +01:00
|
|
|
from anki.utils import isLin, isMac, isWin, namedtmp
|
2020-01-13 05:38:05 +01:00
|
|
|
from aqt import gui_hooks
|
2020-06-26 19:36:58 +02:00
|
|
|
from aqt.mpv import MPV, MPVBase, MPVCommandError
|
2020-01-02 10:43:19 +01:00
|
|
|
from aqt.qt import *
|
2020-01-20 10:52:58 +01:00
|
|
|
from aqt.taskman import TaskManager
|
2021-01-07 05:24:49 +01:00
|
|
|
from aqt.utils import (
|
|
|
|
disable_help_button,
|
|
|
|
restoreGeom,
|
|
|
|
saveGeom,
|
|
|
|
showWarning,
|
|
|
|
startup_info,
|
2021-01-26 11:23:52 +01:00
|
|
|
tooltip,
|
2021-01-07 05:24:49 +01:00
|
|
|
tr,
|
|
|
|
)
|
2020-01-20 10:52:58 +01:00
|
|
|
|
|
|
|
# AV player protocol
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
OnDoneCallback = Callable[[], None]
|
|
|
|
|
|
|
|
|
|
|
|
class Player(ABC):
|
|
|
|
@abstractmethod
|
2020-01-21 02:34:16 +01:00
|
|
|
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
|
2020-01-22 05:39:18 +01:00
|
|
|
"""Play a file.
|
|
|
|
|
|
|
|
When reimplementing, make sure to call
|
|
|
|
gui_hooks.av_player_did_begin_playing(self, tag)
|
|
|
|
on the main thread after playback begins.
|
|
|
|
"""
|
2020-01-02 10:43:19 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
@abstractmethod
|
2021-10-03 10:59:42 +02:00
|
|
|
def rank_for_tag(self, tag: AVTag) -> int | None:
|
2020-01-21 02:34:16 +01:00
|
|
|
"""How suited this player is to playing tag.
|
|
|
|
|
|
|
|
AVPlayer will choose the player that returns the highest rank
|
|
|
|
for a given tag.
|
|
|
|
|
|
|
|
If None, this player can not play the tag.
|
|
|
|
"""
|
2020-01-02 10:43:19 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
def stop(self) -> None:
|
2020-01-20 22:28:19 +01:00
|
|
|
"""Optional.
|
|
|
|
|
|
|
|
If implemented, the player must not call on_done() when the audio is stopped."""
|
2020-01-02 10:43:19 +01:00
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
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."
|
|
|
|
|
2020-01-02 10:43:19 +01:00
|
|
|
|
2020-10-01 11:36:07 +02:00
|
|
|
AUDIO_EXTENSIONS = {
|
2020-10-10 16:22:06 +02:00
|
|
|
"3gp",
|
2020-10-01 11:36:07 +02:00
|
|
|
"flac",
|
|
|
|
"m4a",
|
2020-10-10 16:22:06 +02:00
|
|
|
"mp3",
|
2020-10-01 11:36:07 +02:00
|
|
|
"oga",
|
2020-10-10 16:22:06 +02:00
|
|
|
"ogg",
|
|
|
|
"opus",
|
|
|
|
"spx",
|
|
|
|
"wav",
|
2020-10-01 11:36:07 +02:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
def is_audio_file(fname: str) -> bool:
|
|
|
|
ext = fname.split(".")[-1].lower()
|
|
|
|
return ext in AUDIO_EXTENSIONS
|
|
|
|
|
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method
|
2020-01-21 02:34:16 +01:00
|
|
|
default_rank = 0
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def rank_for_tag(self, tag: AVTag) -> int | None:
|
2020-01-21 02:34:16 +01:00
|
|
|
if isinstance(tag, SoundOrVideoTag):
|
|
|
|
return self.default_rank
|
|
|
|
else:
|
|
|
|
return None
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2020-10-01 11:36:07 +02:00
|
|
|
class SoundPlayer(Player): # pylint: disable=abstract-method
|
|
|
|
default_rank = 0
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def rank_for_tag(self, tag: AVTag) -> int | None:
|
2020-10-01 11:36:07 +02:00
|
|
|
if isinstance(tag, SoundOrVideoTag) and is_audio_file(tag.filename):
|
|
|
|
return self.default_rank
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
|
|
|
class VideoPlayer(Player): # pylint: disable=abstract-method
|
|
|
|
default_rank = 0
|
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def rank_for_tag(self, tag: AVTag) -> int | None:
|
2020-10-01 11:36:07 +02:00
|
|
|
if isinstance(tag, SoundOrVideoTag) and not is_audio_file(tag.filename):
|
|
|
|
return self.default_rank
|
|
|
|
else:
|
|
|
|
return None
|
|
|
|
|
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
# Main playing interface
|
2012-12-21 08:51:59 +01:00
|
|
|
##########################################################################
|
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
class AVPlayer:
|
2021-10-03 10:59:42 +02:00
|
|
|
players: list[Player] = []
|
2021-01-10 16:18:08 +01:00
|
|
|
# when a new batch of audio is played, should the currently playing
|
2020-01-20 21:45:32 +01:00
|
|
|
# audio be stopped?
|
|
|
|
interrupt_current_audio = True
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def __init__(self) -> None:
|
2021-10-03 10:59:42 +02:00
|
|
|
self._enqueued: list[AVTag] = []
|
|
|
|
self.current_player: Player | None = None
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def play_tags(self, tags: list[AVTag]) -> None:
|
2020-01-20 10:52:58 +01:00
|
|
|
"""Clear the existing queue, then start playing provided tags."""
|
2020-03-14 11:04:19 +01:00
|
|
|
self.clear_queue_and_maybe_interrupt()
|
2020-01-21 05:43:33 +01:00
|
|
|
self._enqueued = tags[:]
|
2020-01-20 10:52:58 +01:00
|
|
|
self._play_next_if_idle()
|
|
|
|
|
|
|
|
def stop_and_clear_queue(self) -> None:
|
|
|
|
self._enqueued = []
|
|
|
|
self._stop_if_playing()
|
|
|
|
|
2020-03-14 11:04:19 +01:00
|
|
|
def clear_queue_and_maybe_interrupt(self) -> None:
|
|
|
|
self._enqueued = []
|
2020-02-25 08:49:06 +01:00
|
|
|
if self.interrupt_current_audio:
|
|
|
|
self._stop_if_playing()
|
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
def play_file(self, filename: str) -> None:
|
|
|
|
self.play_tags([SoundOrVideoTag(filename=filename)])
|
|
|
|
|
2020-01-21 04:21:43 +01:00
|
|
|
def insert_file(self, filename: str) -> None:
|
|
|
|
self._enqueued.insert(0, SoundOrVideoTag(filename=filename))
|
|
|
|
self._play_next_if_idle()
|
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def toggle_pause(self) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
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()
|
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
def _stop_if_playing(self) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
if self.current_player:
|
|
|
|
self.current_player.stop()
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def _pop_next(self) -> AVTag | None:
|
2020-01-20 10:52:58 +01:00
|
|
|
if not self._enqueued:
|
|
|
|
return None
|
|
|
|
return self._enqueued.pop(0)
|
|
|
|
|
|
|
|
def _on_play_finished(self) -> None:
|
2020-01-22 05:39:18 +01:00
|
|
|
gui_hooks.av_player_did_end_playing(self.current_player)
|
2020-01-20 23:55:15 +01:00
|
|
|
self.current_player = None
|
2020-01-20 10:52:58 +01:00
|
|
|
self._play_next_if_idle()
|
|
|
|
|
|
|
|
def _play_next_if_idle(self) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
if self.current_player:
|
2020-01-20 10:52:58 +01:00
|
|
|
return
|
|
|
|
|
|
|
|
next = self._pop_next()
|
|
|
|
if next is not None:
|
|
|
|
self._play(next)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
def _play(self, tag: AVTag) -> None:
|
2020-01-21 02:34:16 +01:00
|
|
|
best_player = self._best_player_for_tag(tag)
|
|
|
|
if best_player:
|
|
|
|
self.current_player = best_player
|
|
|
|
gui_hooks.av_player_will_play(tag)
|
|
|
|
self.current_player.play(tag, self._on_play_finished)
|
|
|
|
else:
|
2021-01-26 11:23:52 +01:00
|
|
|
tooltip(f"no players found for {tag}")
|
2020-01-21 02:34:16 +01:00
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
def _best_player_for_tag(self, tag: AVTag) -> Player | None:
|
2020-01-21 02:34:16 +01:00
|
|
|
ranked = []
|
|
|
|
for p in self.players:
|
|
|
|
rank = p.rank_for_tag(tag)
|
|
|
|
if rank is not None:
|
|
|
|
ranked.append((rank, p))
|
|
|
|
|
2020-01-21 09:32:53 +01:00
|
|
|
ranked.sort(key=itemgetter(0))
|
2020-01-21 02:34:16 +01:00
|
|
|
|
|
|
|
if ranked:
|
|
|
|
return ranked[-1][1]
|
|
|
|
else:
|
|
|
|
return None
|
2020-01-20 10:52:58 +01:00
|
|
|
|
|
|
|
|
|
|
|
av_player = AVPlayer()
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2016-07-26 04:15:43 +02:00
|
|
|
# Packaged commands
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
# return modified command array that points to bundled command, and return
|
|
|
|
# required environment
|
2021-10-03 10:59:42 +02:00
|
|
|
def _packagedCmd(cmd: list[str]) -> tuple[Any, dict[str, str]]:
|
2016-07-26 04:15:43 +02:00
|
|
|
cmd = cmd[:]
|
|
|
|
env = os.environ.copy()
|
2017-05-22 07:40:04 +02:00
|
|
|
if "LD_LIBRARY_PATH" in env:
|
2019-12-23 01:34:10 +01:00
|
|
|
del env["LD_LIBRARY_PATH"]
|
2021-10-29 10:10:45 +02:00
|
|
|
|
2021-10-29 23:34:24 +02:00
|
|
|
packaged_path = Path(sys.prefix) / "audio" / (cmd[0] + (".exe" if isWin else ""))
|
2021-10-29 10:10:45 +02:00
|
|
|
if packaged_path.exists():
|
|
|
|
cmd[0] = str(packaged_path)
|
|
|
|
|
2016-07-26 04:15:43 +02:00
|
|
|
return cmd, env
|
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-23 07:10:41 +01:00
|
|
|
# Platform hacks
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
# legacy global for add-ons
|
|
|
|
si = startup_info()
|
|
|
|
|
|
|
|
# osx throws interrupted system call errors frequently
|
2020-02-27 03:56:45 +01:00
|
|
|
def retryWait(proc: subprocess.Popen) -> int:
|
2020-01-23 07:10:41 +01:00
|
|
|
while 1:
|
|
|
|
try:
|
|
|
|
return proc.wait()
|
|
|
|
except OSError:
|
|
|
|
continue
|
|
|
|
|
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
# Simple player implementations
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
|
2020-01-21 02:34:16 +01:00
|
|
|
class SimpleProcessPlayer(Player): # pylint: disable=abstract-method
|
|
|
|
"A player that invokes a new process for each tag to play."
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2021-10-03 10:59:42 +02:00
|
|
|
args: list[str] = []
|
|
|
|
env: dict[str, str] | None = None
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def __init__(self, taskman: TaskManager) -> None:
|
2020-01-20 21:49:09 +01:00
|
|
|
self._taskman = taskman
|
2020-01-21 02:34:16 +01:00
|
|
|
self._terminate_flag = False
|
2021-10-03 10:59:42 +02:00
|
|
|
self._process: subprocess.Popen | None = None
|
2020-01-20 21:49:09 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
|
2020-03-15 00:26:31 +01:00
|
|
|
self._terminate_flag = False
|
2020-01-22 05:09:51 +01:00
|
|
|
self._taskman.run_in_background(
|
2020-01-21 02:34:16 +01:00
|
|
|
lambda: self._play(tag), lambda res: self._on_done(res, on_done)
|
2020-01-20 22:28:19 +01:00
|
|
|
)
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def stop(self) -> None:
|
2020-01-20 10:52:58 +01:00
|
|
|
self._terminate_flag = True
|
|
|
|
|
2020-03-15 00:26:31 +01:00
|
|
|
# note: mplayer implementation overrides this
|
2020-01-21 02:34:16 +01:00
|
|
|
def _play(self, tag: AVTag) -> None:
|
|
|
|
assert isinstance(tag, SoundOrVideoTag)
|
2020-01-23 07:10:41 +01:00
|
|
|
self._process = subprocess.Popen(
|
2020-03-14 09:53:43 +01:00
|
|
|
self.args + [tag.filename],
|
|
|
|
env=self.env,
|
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
stderr=subprocess.DEVNULL,
|
2020-01-23 07:10:41 +01:00
|
|
|
)
|
2020-01-22 05:39:18 +01:00
|
|
|
self._wait_for_termination(tag)
|
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def _wait_for_termination(self, tag: AVTag) -> None:
|
2020-01-22 05:39:18 +01:00
|
|
|
self._taskman.run_on_main(
|
|
|
|
lambda: gui_hooks.av_player_did_begin_playing(self, tag)
|
|
|
|
)
|
2020-01-21 02:34:16 +01:00
|
|
|
|
2020-02-21 06:14:09 +01:00
|
|
|
while True:
|
2020-03-15 00:34:04 +01:00
|
|
|
# should we abort playing?
|
|
|
|
if self._terminate_flag:
|
|
|
|
self._process.terminate()
|
2021-08-19 02:33:56 +02:00
|
|
|
self._process.wait(1)
|
2021-09-27 10:43:16 +02:00
|
|
|
try:
|
|
|
|
if self._process.stdin:
|
|
|
|
self._process.stdin.close()
|
|
|
|
except Exception as e:
|
|
|
|
print("unable to close stdin:", e)
|
2020-03-15 00:34:04 +01:00
|
|
|
self._process = None
|
|
|
|
return
|
|
|
|
|
|
|
|
# wait for completion
|
|
|
|
try:
|
|
|
|
self._process.wait(0.1)
|
|
|
|
if self._process.returncode != 0:
|
|
|
|
print(f"player got return code: {self._process.returncode}")
|
2021-09-27 10:43:16 +02:00
|
|
|
try:
|
|
|
|
if self._process.stdin:
|
|
|
|
self._process.stdin.close()
|
|
|
|
except Exception as e:
|
|
|
|
print("unable to close stdin:", e)
|
2020-03-15 00:34:04 +01:00
|
|
|
self._process = None
|
|
|
|
return
|
|
|
|
except subprocess.TimeoutExpired:
|
|
|
|
# process still running, repeat loop
|
|
|
|
pass
|
2020-01-20 22:28:19 +01:00
|
|
|
|
|
|
|
def _on_done(self, ret: Future, cb: OnDoneCallback) -> None:
|
|
|
|
try:
|
|
|
|
ret.result()
|
2020-02-29 12:05:37 +01:00
|
|
|
except FileNotFoundError:
|
2021-03-26 04:48:26 +01:00
|
|
|
showWarning(tr.media_sound_and_video_on_cards_will())
|
2020-02-29 12:05:37 +01:00
|
|
|
# must call cb() here, as we don't currently have another way
|
|
|
|
# to flag to av_player that we've stopped
|
2020-01-20 22:28:19 +01:00
|
|
|
cb()
|
2020-01-20 10:52:58 +01:00
|
|
|
|
|
|
|
|
2020-10-01 11:36:07 +02:00
|
|
|
class SimpleMpvPlayer(SimpleProcessPlayer, VideoPlayer):
|
|
|
|
default_rank = 1
|
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
args, env = _packagedCmd(
|
|
|
|
[
|
|
|
|
"mpv",
|
|
|
|
"--no-terminal",
|
|
|
|
"--force-window=no",
|
|
|
|
"--ontop",
|
|
|
|
"--audio-display=no",
|
|
|
|
"--keep-open=no",
|
|
|
|
"--input-media-keys=no",
|
2020-10-01 11:36:07 +02:00
|
|
|
"--autoload-files=no",
|
2020-01-20 10:52:58 +01:00
|
|
|
]
|
|
|
|
)
|
|
|
|
|
2020-01-20 21:49:09 +01:00
|
|
|
def __init__(self, taskman: TaskManager, base_folder: str) -> None:
|
|
|
|
super().__init__(taskman)
|
2021-02-11 01:09:06 +01:00
|
|
|
self.args += [f"--config-dir={base_folder}"]
|
2020-01-20 13:01:38 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2020-01-21 02:34:16 +01:00
|
|
|
class SimpleMplayerPlayer(SimpleProcessPlayer, SoundOrVideoPlayer):
|
2020-01-20 10:52:58 +01:00
|
|
|
args, env = _packagedCmd(["mplayer", "-really-quiet", "-noautosub"])
|
|
|
|
if isWin:
|
|
|
|
args += ["-ao", "win32"]
|
|
|
|
|
|
|
|
|
2017-09-30 09:24:56 +02:00
|
|
|
# MPV
|
2012-12-21 08:51:59 +01:00
|
|
|
##########################################################################
|
|
|
|
|
2017-09-30 09:24:56 +02:00
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
class MpvManager(MPV, SoundOrVideoPlayer):
|
2017-10-02 08:37:52 +02:00
|
|
|
|
2018-01-21 01:34:29 +01:00
|
|
|
if not isLin:
|
|
|
|
default_argv = MPVBase.default_argv + [
|
|
|
|
"--input-media-keys=no",
|
|
|
|
]
|
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
def __init__(self, base_path: str) -> None:
|
|
|
|
mpvPath, self.popenEnv = _packagedCmd(["mpv"])
|
|
|
|
self.executable = mpvPath[0]
|
2021-10-03 10:59:42 +02:00
|
|
|
self._on_done: OnDoneCallback | None = None
|
2021-02-11 01:09:06 +01:00
|
|
|
self.default_argv += [f"--config-dir={base_path}"]
|
2020-01-23 22:06:59 +01:00
|
|
|
super().__init__(window_id=None, debug=False)
|
2017-09-30 09:24:56 +02:00
|
|
|
|
2020-06-26 19:36:58 +02:00
|
|
|
def on_init(self) -> None:
|
2020-08-21 03:10:30 +02:00
|
|
|
# if mpv dies and is restarted, tell Anki the
|
|
|
|
# current file is done
|
2020-09-16 08:25:18 +02:00
|
|
|
if self._on_done:
|
|
|
|
self._on_done()
|
2020-08-21 03:45:14 +02:00
|
|
|
|
2020-06-26 19:36:58 +02:00
|
|
|
try:
|
|
|
|
self.command("keybind", "q", "stop")
|
|
|
|
self.command("keybind", "Q", "stop")
|
|
|
|
self.command("keybind", "CLOSE_WIN", "stop")
|
|
|
|
self.command("keybind", "ctrl+w", "stop")
|
|
|
|
self.command("keybind", "ctrl+c", "stop")
|
|
|
|
except MPVCommandError:
|
2020-07-24 03:57:37 +02:00
|
|
|
print("mpv too old for key rebinding")
|
2020-06-26 19:36:58 +02:00
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
def play(self, tag: AVTag, on_done: OnDoneCallback) -> None:
|
2020-01-21 02:34:16 +01:00
|
|
|
assert isinstance(tag, SoundOrVideoTag)
|
2020-01-20 13:01:38 +01:00
|
|
|
self._on_done = on_done
|
2020-11-09 10:45:14 +01:00
|
|
|
filename = hooks.media_file_filter(tag.filename)
|
|
|
|
path = os.path.join(os.getcwd(), filename)
|
|
|
|
|
2021-06-10 10:17:26 +02:00
|
|
|
self.command("loadfile", path, "replace", "pause=no")
|
2020-01-22 05:39:18 +01:00
|
|
|
gui_hooks.av_player_did_begin_playing(self, tag)
|
2017-09-30 09:24:56 +02:00
|
|
|
|
2020-01-20 21:45:32 +01:00
|
|
|
def stop(self) -> None:
|
|
|
|
self.command("stop")
|
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
def toggle_pause(self) -> None:
|
2021-06-07 00:54:07 +02:00
|
|
|
self.command("cycle", "pause")
|
2017-09-30 09:24:56 +02:00
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def seek_relative(self, secs: int) -> None:
|
2017-09-30 09:24:56 +02:00
|
|
|
self.command("seek", secs, "relative")
|
|
|
|
|
2020-09-16 08:25:18 +02:00
|
|
|
def on_property_idle_active(self, value: bool) -> None:
|
|
|
|
if value and self._on_done:
|
2020-01-20 13:01:38 +01:00
|
|
|
self._on_done()
|
2018-04-30 09:12:26 +02:00
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
def shutdown(self) -> None:
|
|
|
|
self.close()
|
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
# Legacy, not used
|
|
|
|
##################################################
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
togglePause = toggle_pause
|
|
|
|
seekRelative = seek_relative
|
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
def queueFile(self, file: str) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
return
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
def clearQueue(self) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
return
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
# Mplayer in slave mode
|
|
|
|
##########################################################################
|
|
|
|
|
2019-02-06 00:03:39 +01:00
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
class SimpleMplayerSlaveModePlayer(SimpleMplayerPlayer):
|
2021-02-01 14:28:21 +01:00
|
|
|
def __init__(self, taskman: TaskManager) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
super().__init__(taskman)
|
|
|
|
self.args.append("-slave")
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-01-21 02:34:16 +01:00
|
|
|
def _play(self, tag: AVTag) -> None:
|
|
|
|
assert isinstance(tag, SoundOrVideoTag)
|
2020-11-09 10:45:14 +01:00
|
|
|
|
|
|
|
filename = hooks.media_file_filter(tag.filename)
|
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
self._process = subprocess.Popen(
|
2020-11-09 10:45:14 +01:00
|
|
|
self.args + [filename],
|
2020-01-23 07:10:41 +01:00
|
|
|
env=self.env,
|
|
|
|
stdin=subprocess.PIPE,
|
2020-03-14 09:53:43 +01:00
|
|
|
stdout=subprocess.DEVNULL,
|
|
|
|
stderr=subprocess.DEVNULL,
|
2020-01-23 07:10:41 +01:00
|
|
|
startupinfo=startup_info(),
|
2020-01-20 23:55:15 +01:00
|
|
|
)
|
2020-01-22 05:39:18 +01:00
|
|
|
self._wait_for_termination(tag)
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def command(self, *args: Any) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
"""Send a command over the slave interface.
|
2019-12-20 06:07:40 +01:00
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
The trailing newline is automatically added."""
|
2020-01-22 05:11:25 +01:00
|
|
|
str_args = [str(x) for x in args]
|
2020-03-23 10:15:32 +01:00
|
|
|
if self._process:
|
|
|
|
self._process.stdin.write(" ".join(str_args).encode("utf8") + b"\n")
|
|
|
|
self._process.stdin.flush()
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-20 23:55:15 +01:00
|
|
|
def seek_relative(self, secs: int) -> None:
|
2020-01-22 05:11:25 +01:00
|
|
|
self.command("seek", secs, 0)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-02-27 03:56:45 +01:00
|
|
|
def toggle_pause(self) -> None:
|
2020-01-20 23:55:15 +01:00
|
|
|
self.command("pause")
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
# MP3 transcoding
|
2012-12-21 08:51:59 +01:00
|
|
|
##########################################################################
|
|
|
|
|
2014-04-17 21:17:05 +02:00
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
def _encode_mp3(src_wav: str, dst_mp3: str) -> None:
|
|
|
|
cmd = ["lame", src_wav, dst_mp3, "--noreplaygain", "--quiet"]
|
|
|
|
cmd, env = _packagedCmd(cmd)
|
|
|
|
try:
|
|
|
|
retcode = retryWait(subprocess.Popen(cmd, startupinfo=startup_info(), env=env))
|
|
|
|
except Exception as e:
|
2021-03-26 05:21:04 +01:00
|
|
|
raise Exception(tr.media_error_running(val=" ").join(cmd)) from e
|
2020-12-16 10:09:45 +01:00
|
|
|
if retcode != 0:
|
2021-03-26 05:21:04 +01:00
|
|
|
raise Exception(tr.media_error_running(val=" ").join(cmd))
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
os.unlink(src_wav)
|
2020-01-20 12:03:22 +01:00
|
|
|
|
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
def encode_mp3(mw: aqt.AnkiQt, src_wav: str, on_done: Callable[[str], None]) -> None:
|
|
|
|
"Encode the provided wav file to .mp3, and call on_done() with the path."
|
|
|
|
dst_mp3 = src_wav.replace(".wav", "%d.mp3" % time.time())
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _on_done(fut: Future) -> None:
|
2021-03-29 11:52:30 +02:00
|
|
|
if exc := fut.exception():
|
|
|
|
print(exc)
|
|
|
|
showWarning(tr.editing_couldnt_record_audio_have_you_installed())
|
|
|
|
return
|
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
on_done(dst_mp3)
|
2017-12-05 02:07:52 +01:00
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
mw.taskman.run_in_background(lambda: _encode_mp3(src_wav, dst_mp3), _on_done)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-12-18 07:52:00 +01:00
|
|
|
# Recording interface
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
|
|
|
|
class Recorder(ABC):
|
|
|
|
# seconds to wait before recording
|
|
|
|
STARTUP_DELAY = 0.3
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def __init__(self, output_path: str) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
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()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def stop(self, on_done: Callable[[str], None]) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
"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
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def on_timer(self) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
"Will be called periodically."
|
|
|
|
|
|
|
|
|
2020-12-18 09:59:10 +01:00
|
|
|
# QAudioInput recording
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
|
2021-10-08 07:55:38 +02:00
|
|
|
class QtAudioInputRecorder(Recorder):
|
|
|
|
def __init__(self, output_path: str, mw: aqt.AnkiQt, parent: QWidget) -> None:
|
|
|
|
super().__init__(output_path)
|
|
|
|
|
|
|
|
self.mw = mw
|
|
|
|
self._parent = parent
|
|
|
|
|
|
|
|
from PyQt6.QtMultimedia import QAudioFormat, QAudioSource # type: ignore
|
|
|
|
|
|
|
|
format = QAudioFormat()
|
|
|
|
format.setChannelCount(1)
|
|
|
|
format.setSampleRate(44100)
|
|
|
|
format.setSampleFormat(QAudioFormat.SampleFormat.Int16)
|
|
|
|
|
|
|
|
source = QAudioSource(format, parent)
|
|
|
|
|
|
|
|
self._format = source.format()
|
|
|
|
self._audio_input = source
|
|
|
|
|
|
|
|
def start(self, on_done: Callable[[], None]) -> None:
|
|
|
|
self._iodevice = self._audio_input.start()
|
|
|
|
self._buffer = bytearray()
|
|
|
|
qconnect(self._iodevice.readyRead, self._on_read_ready)
|
|
|
|
super().start(on_done)
|
|
|
|
|
|
|
|
def _on_read_ready(self) -> None:
|
|
|
|
self._buffer.extend(cast(bytes, self._iodevice.readAll()))
|
|
|
|
|
|
|
|
def stop(self, on_done: Callable[[str], None]) -> None:
|
|
|
|
from PyQt6.QtMultimedia import QAudio
|
|
|
|
|
|
|
|
def on_stop_timer() -> None:
|
|
|
|
# read anything remaining in buffer & stop
|
|
|
|
self._on_read_ready()
|
|
|
|
self._audio_input.stop()
|
|
|
|
|
|
|
|
if (err := self._audio_input.error()) != QAudio.Error.NoError:
|
|
|
|
showWarning(f"recording failed: {err}")
|
|
|
|
return
|
|
|
|
|
|
|
|
def write_file() -> None:
|
|
|
|
# swallow the first 300ms to allow audio device to quiesce
|
|
|
|
wait = int(44100 * self.STARTUP_DELAY)
|
|
|
|
if len(self._buffer) <= wait:
|
|
|
|
return
|
|
|
|
self._buffer = self._buffer[wait:]
|
|
|
|
|
|
|
|
# write out the wave file
|
|
|
|
wf = wave.open(self.output_path, "wb")
|
|
|
|
wf.setnchannels(self._format.channelCount())
|
|
|
|
wf.setsampwidth(2)
|
|
|
|
wf.setframerate(self._format.sampleRate())
|
|
|
|
wf.writeframes(self._buffer)
|
|
|
|
wf.close()
|
|
|
|
|
|
|
|
def and_then(fut: Future) -> None:
|
|
|
|
fut.result()
|
|
|
|
Recorder.stop(self, on_done)
|
|
|
|
|
|
|
|
self.mw.taskman.run_in_background(write_file, and_then)
|
|
|
|
|
|
|
|
# schedule the stop for half a second in the future,
|
|
|
|
# to avoid truncating the end of the recording
|
|
|
|
self._stop_timer = t = QTimer(self._parent)
|
|
|
|
t.timeout.connect(on_stop_timer) # type: ignore
|
|
|
|
t.setSingleShot(True)
|
|
|
|
t.start(500)
|
2020-12-18 09:59:10 +01:00
|
|
|
|
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
# Recording dialog
|
|
|
|
##########################################################################
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2017-12-05 02:07:52 +01:00
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
class RecordDialog(QDialog):
|
2020-12-18 07:52:00 +01:00
|
|
|
_recorder: Recorder
|
2020-12-16 10:09:45 +01:00
|
|
|
|
|
|
|
def __init__(
|
|
|
|
self,
|
|
|
|
parent: QWidget,
|
|
|
|
mw: aqt.AnkiQt,
|
|
|
|
on_success: Callable[[str], None],
|
|
|
|
):
|
|
|
|
QDialog.__init__(self, parent)
|
|
|
|
self._parent = parent
|
|
|
|
self.mw = mw
|
|
|
|
self._on_success = on_success
|
2021-01-07 05:24:49 +01:00
|
|
|
disable_help_button(self)
|
2020-12-16 10:09:45 +01:00
|
|
|
|
|
|
|
self._start_recording()
|
|
|
|
self._setup_dialog()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _setup_dialog(self) -> None:
|
2020-12-16 10:09:45 +01:00
|
|
|
self.setWindowTitle("Anki")
|
|
|
|
icon = QLabel()
|
2021-10-05 06:44:07 +02:00
|
|
|
icon.setPixmap(QPixmap("icons:media-record.png"))
|
2020-12-16 10:45:08 +01:00
|
|
|
self.label = QLabel("...")
|
2020-12-16 10:09:45 +01:00
|
|
|
hbox = QHBoxLayout()
|
|
|
|
hbox.addWidget(icon)
|
|
|
|
hbox.addWidget(self.label)
|
|
|
|
v = QVBoxLayout()
|
|
|
|
v.addLayout(hbox)
|
2021-10-05 05:53:01 +02:00
|
|
|
buts = (
|
|
|
|
QDialogButtonBox.StandardButton.Save
|
|
|
|
| QDialogButtonBox.StandardButton.Cancel
|
|
|
|
)
|
2020-12-16 10:09:45 +01:00
|
|
|
b = QDialogButtonBox(buts) # type: ignore
|
|
|
|
v.addWidget(b)
|
|
|
|
self.setLayout(v)
|
2021-10-05 05:53:01 +02:00
|
|
|
save_button = b.button(QDialogButtonBox.StandardButton.Save)
|
2020-12-16 10:09:45 +01:00
|
|
|
save_button.setDefault(True)
|
|
|
|
save_button.setAutoDefault(True)
|
|
|
|
qconnect(save_button.clicked, self.accept)
|
2021-10-05 05:53:01 +02:00
|
|
|
cancel_button = b.button(QDialogButtonBox.StandardButton.Cancel)
|
2020-12-16 10:09:45 +01:00
|
|
|
cancel_button.setDefault(False)
|
|
|
|
cancel_button.setAutoDefault(False)
|
|
|
|
qconnect(cancel_button.clicked, self.reject)
|
|
|
|
restoreGeom(self, "audioRecorder2")
|
|
|
|
self.show()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _save_diag(self) -> None:
|
2020-12-16 10:09:45 +01:00
|
|
|
saveGeom(self, "audioRecorder2")
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _start_recording(self) -> None:
|
2021-10-11 10:23:38 +02:00
|
|
|
if qtmajor > 5:
|
|
|
|
self._recorder = QtAudioInputRecorder(
|
|
|
|
namedtmp("rec.wav"), self.mw, self._parent
|
|
|
|
)
|
2020-12-18 07:52:00 +01:00
|
|
|
else:
|
2021-10-29 05:54:24 +02:00
|
|
|
from aqt.qt.qt5_audio import QtAudioInputRecorder as Qt5Recorder
|
2021-10-11 10:23:38 +02:00
|
|
|
|
|
|
|
self._recorder = Qt5Recorder(namedtmp("rec.wav"), self.mw, self._parent)
|
2020-12-18 07:52:00 +01:00
|
|
|
self._recorder.start(self._start_timer)
|
2020-12-16 10:09:45 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _start_timer(self) -> None:
|
2020-12-16 10:09:45 +01:00
|
|
|
self._timer = t = QTimer(self._parent)
|
2020-12-16 10:51:46 +01:00
|
|
|
t.timeout.connect(self._on_timer) # type: ignore
|
2020-12-16 10:09:45 +01:00
|
|
|
t.setSingleShot(False)
|
2020-12-16 10:45:08 +01:00
|
|
|
t.start(100)
|
2020-12-16 10:09:45 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def _on_timer(self) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
self._recorder.on_timer()
|
2020-12-16 10:09:45 +01:00
|
|
|
duration = self._recorder.duration()
|
2021-03-26 05:21:04 +01:00
|
|
|
self.label.setText(tr.media_recordingtime(secs=f"{duration:0.1f}"))
|
2020-12-16 10:09:45 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def accept(self) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
self._timer.stop()
|
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
try:
|
|
|
|
self._save_diag()
|
2021-01-07 07:20:02 +01:00
|
|
|
self._recorder.stop(self._on_success)
|
2020-12-16 10:09:45 +01:00
|
|
|
finally:
|
|
|
|
QDialog.accept(self)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def reject(self) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
self._timer.stop()
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def cleanup(out: str) -> None:
|
2020-12-18 07:52:00 +01:00
|
|
|
os.unlink(out)
|
|
|
|
|
2020-12-16 10:09:45 +01:00
|
|
|
try:
|
2020-12-18 07:52:00 +01:00
|
|
|
self._recorder.stop(cleanup)
|
2020-12-16 10:09:45 +01:00
|
|
|
finally:
|
|
|
|
QDialog.reject(self)
|
|
|
|
|
|
|
|
|
|
|
|
def record_audio(
|
|
|
|
parent: QWidget, mw: aqt.AnkiQt, encode: bool, on_done: Callable[[str], None]
|
2021-02-02 14:30:53 +01:00
|
|
|
) -> None:
|
2021-10-29 06:43:59 +02:00
|
|
|
if sys.platform.startswith("darwin") and platform.machine() == "arm64":
|
|
|
|
showWarning("Recording currently only works in Anki's Intel build")
|
|
|
|
return
|
|
|
|
|
2021-02-01 14:28:21 +01:00
|
|
|
def after_record(path: str) -> None:
|
2020-12-16 10:09:45 +01:00
|
|
|
if not encode:
|
|
|
|
on_done(path)
|
2012-12-21 08:51:59 +01:00
|
|
|
else:
|
2020-12-16 10:09:45 +01:00
|
|
|
encode_mp3(mw, path, on_done)
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2021-06-02 04:14:20 +02:00
|
|
|
try:
|
|
|
|
_diag = RecordDialog(parent, mw, after_record)
|
|
|
|
except Exception as e:
|
|
|
|
err_str = str(e)
|
|
|
|
showWarning(markdown(tr.qt_misc_unable_to_record(error=err_str)))
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-20 10:52:58 +01:00
|
|
|
|
|
|
|
# Legacy audio interface
|
|
|
|
##########################################################################
|
|
|
|
# these will be removed in the future
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2019-12-19 04:02:45 +01:00
|
|
|
def clearAudioQueue() -> None:
|
2020-01-20 10:52:58 +01:00
|
|
|
av_player.stop_and_clear_queue()
|
|
|
|
|
|
|
|
|
|
|
|
def play(filename: str) -> None:
|
|
|
|
av_player.play_file(filename)
|
|
|
|
|
|
|
|
|
2021-02-02 14:30:53 +01:00
|
|
|
def playFromText(text: Any) -> None:
|
2020-01-24 02:06:11 +01:00
|
|
|
print("playFromText() deprecated")
|
2020-01-20 10:52:58 +01:00
|
|
|
|
2012-12-21 08:51:59 +01:00
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
# legacy globals
|
2020-01-20 10:52:58 +01:00
|
|
|
_player = play
|
|
|
|
_queueEraser = clearAudioQueue
|
2021-10-03 10:59:42 +02:00
|
|
|
mpvManager: MpvManager | None = None
|
2019-12-23 01:34:10 +01:00
|
|
|
|
2020-01-02 10:43:19 +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
|
2020-01-20 13:01:38 +01:00
|
|
|
|
2020-01-21 05:47:03 +01:00
|
|
|
# Tag handling
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
|
2020-01-24 02:06:11 +01:00
|
|
|
def av_refs_to_play_icons(text: str) -> str:
|
|
|
|
"""Add play icons into the HTML.
|
|
|
|
|
|
|
|
When clicked, the icon will call eg pycmd('play:q:1').
|
|
|
|
"""
|
|
|
|
|
2020-01-21 05:47:03 +01:00
|
|
|
def repl(match: re.Match) -> str:
|
|
|
|
return f"""
|
2020-01-30 22:01:22 +01:00
|
|
|
<a class="replay-button soundLink" href=# onclick="pycmd('{match.group(1)}'); return false;">
|
2020-01-28 01:11:29 +01:00
|
|
|
<svg class="playImage" viewBox="0 0 64 64" version="1.1">
|
2020-01-30 22:23:35 +01:00
|
|
|
<circle cx="32" cy="32" r="29" />
|
|
|
|
<path d="M56.502,32.301l-37.502,20.101l0.329,-40.804l37.173,20.703Z" />
|
2020-01-28 01:11:29 +01:00
|
|
|
</svg>
|
2020-01-21 05:47:03 +01:00
|
|
|
</a>"""
|
|
|
|
|
2020-01-24 06:48:40 +01:00
|
|
|
return AV_REF_RE.sub(repl, text)
|
2020-01-21 05:47:03 +01:00
|
|
|
|
|
|
|
|
2020-01-24 02:06:11 +01:00
|
|
|
def play_clicked_audio(pycmd: str, card: Card) -> None:
|
|
|
|
"""eg. if pycmd is 'play:q:0', play the first audio on the question side."""
|
|
|
|
play, context, str_idx = pycmd.split(":")
|
|
|
|
idx = int(str_idx)
|
|
|
|
if context == "q":
|
|
|
|
tags = card.question_av_tags()
|
|
|
|
else:
|
|
|
|
tags = card.answer_av_tags()
|
|
|
|
av_player.play_tags([tags[idx]])
|
|
|
|
|
|
|
|
|
2020-01-20 13:01:38 +01:00
|
|
|
# Init defaults
|
|
|
|
##########################################################################
|
|
|
|
|
|
|
|
|
2020-01-20 21:49:09 +01:00
|
|
|
def setup_audio(taskman: TaskManager, base_folder: str) -> None:
|
2020-01-20 13:01:38 +01:00
|
|
|
# legacy global var
|
|
|
|
global mpvManager
|
|
|
|
|
2020-05-27 01:21:41 +02:00
|
|
|
try:
|
|
|
|
mpvManager = MpvManager(base_folder)
|
|
|
|
except FileNotFoundError:
|
|
|
|
print("mpv not found, reverting to mplayer")
|
|
|
|
except aqt.mpv.MPVProcessError:
|
|
|
|
print("mpv too old, reverting to mplayer")
|
2020-01-20 13:01:38 +01:00
|
|
|
|
|
|
|
if mpvManager is not None:
|
|
|
|
av_player.players.append(mpvManager)
|
2020-10-01 11:36:07 +02:00
|
|
|
|
|
|
|
if isWin:
|
|
|
|
mpvPlayer = SimpleMpvPlayer(taskman, base_folder)
|
|
|
|
av_player.players.append(mpvPlayer)
|
2020-01-20 13:01:38 +01:00
|
|
|
else:
|
2020-01-20 23:55:15 +01:00
|
|
|
mplayer = SimpleMplayerSlaveModePlayer(taskman)
|
2020-01-20 13:01:38 +01:00
|
|
|
av_player.players.append(mplayer)
|
|
|
|
|
|
|
|
# tts support
|
|
|
|
if isMac:
|
|
|
|
from aqt.tts import MacTTSPlayer
|
|
|
|
|
2020-01-20 21:49:09 +01:00
|
|
|
av_player.players.append(MacTTSPlayer(taskman))
|
2020-01-21 08:34:47 +01:00
|
|
|
elif isWin:
|
2020-12-21 02:43:09 +01:00
|
|
|
from aqt.tts import WindowsTTSPlayer
|
2020-01-20 23:55:15 +01:00
|
|
|
|
2020-01-21 08:34:47 +01:00
|
|
|
av_player.players.append(WindowsTTSPlayer(taskman))
|
2020-12-20 03:10:23 +01:00
|
|
|
|
|
|
|
if platform.release() == "10":
|
2020-12-21 02:43:09 +01:00
|
|
|
from aqt.tts import WindowsRTTTSFilePlayer
|
|
|
|
|
2020-12-20 03:10:23 +01:00
|
|
|
# If Windows 10, ensure it's October 2018 update or later
|
|
|
|
if int(platform.version().split(".")[-1]) >= 17763:
|
|
|
|
av_player.players.append(WindowsRTTTSFilePlayer(taskman))
|
2020-01-21 08:34:47 +01:00
|
|
|
|
|
|
|
# cleanup at shutdown
|
2020-01-20 23:55:15 +01:00
|
|
|
atexit.register(av_player.shutdown)
|