From 01f3fd06d641b7de9f1b3db4badba8bab401d693 Mon Sep 17 00:00:00 2001 From: Damien Elmes Date: Tue, 21 Jan 2020 07:28:19 +1000 Subject: [PATCH] support interrupting say; fix race in simple player --- qt/aqt/sound.py | 31 ++++++++++++++++++++++++++----- qt/aqt/tts.py | 44 ++++++++++++++++++++++++++++++++++---------- 2 files changed, 60 insertions(+), 15 deletions(-) diff --git a/qt/aqt/sound.py b/qt/aqt/sound.py index 7be1a5778..3131e8789 100644 --- a/qt/aqt/sound.py +++ b/qt/aqt/sound.py @@ -10,6 +10,7 @@ import threading import time import wave from abc import ABC, abstractmethod +from concurrent.futures import Future from typing import Any, Callable, Dict, List, Optional, Tuple, cast import pyaudio @@ -41,7 +42,9 @@ class Player(ABC): pass def stop(self) -> None: - "Optional." + """Optional. + + If implemented, the player must not call on_done() when the audio is stopped.""" class SoundOrVideoPlayer(Player): # pylint: disable=abstract-method @@ -153,24 +156,32 @@ def _packagedCmd(cmd) -> Tuple[Any, Dict[str, str]]: ########################################################################## +class PlayerInterrupted(Exception): + pass + + class SimpleProcessPlayer(SoundOrVideoPlayer): "A player that invokes a new process for each file to play." - _on_done: Optional[OnDoneCallback] - _terminate_flag = False args: List[str] = [] env: Optional[Dict[str, str]] = None def __init__(self, taskman: TaskManager): self._taskman = taskman + _terminate_flag = False 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()) + self._taskman.run( + lambda: self._play(stag.filename), lambda res: self._on_done(res, on_done) + ) def stop(self): self._terminate_flag = True + # block until stopped + while self._terminate_flag: + time.sleep(0.1) def _play(self, filename: str) -> None: process = subprocess.Popen(self.args + [filename], env=self.env) @@ -178,12 +189,22 @@ class SimpleProcessPlayer(SoundOrVideoPlayer): try: process.wait(0.1) if process.returncode != 0: - raise Exception(f"player got return code: {process.returncode}") + print(f"player got return code: {process.returncode}") return except subprocess.TimeoutExpired: pass if self._terminate_flag: process.terminate() + self._terminate_flag = False + raise PlayerInterrupted() + + def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: + try: + ret.result() + except PlayerInterrupted: + # don't fire done callback when interrupted + return + cb() class SimpleMpvPlayer(SimpleProcessPlayer): diff --git a/qt/aqt/tts.py b/qt/aqt/tts.py index f0375a312..86637ad8f 100644 --- a/qt/aqt/tts.py +++ b/qt/aqt/tts.py @@ -3,11 +3,12 @@ todo """ import subprocess +import time from concurrent.futures import Future -from typing import Callable, cast +from typing import cast from anki.sound import AVTag, TTSTag -from aqt.sound import OnDoneCallback, Player +from aqt.sound import OnDoneCallback, Player, PlayerInterrupted from aqt.taskman import TaskManager @@ -19,25 +20,48 @@ class TTSPlayer(Player): # pylint: disable=abstract-method class MacTTSPlayer(TTSPlayer): def __init__(self, taskman: TaskManager): self._taskman = taskman + self._terminate_flag = False - def play(self, tag: AVTag, on_done: Callable[[], None]) -> None: + def play(self, tag: AVTag, on_done: OnDoneCallback) -> None: ttag = cast(TTSTag, tag) self._taskman.run( lambda: self._play(ttag), lambda ret: self._on_done(ret, on_done) ) def _play(self, tag: TTSTag) -> None: - ret = subprocess.run( + process = subprocess.Popen( ["say", "-v", "Alex", "-f", "-"], - input=tag.text, - encoding="utf8", - check=True, + stdin=subprocess.PIPE, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, ) + # write the input text to stdin + process.stdin.write(tag.text.encode("utf8")) + process.stdin.close() + # and wait for termination + while True: + try: + process.wait(0.1) + if process.returncode != 0: + print(f"player got return code: {process.returncode}") + return + except subprocess.TimeoutExpired: + pass + if self._terminate_flag: + process.terminate() + self._terminate_flag = False + raise PlayerInterrupted() def _on_done(self, ret: Future, cb: OnDoneCallback) -> None: - # will raise on error - ret.result() + try: + ret.result() + except PlayerInterrupted: + # don't fire done callback when interrupted + return cb() def stop(self): - pass + self._terminate_flag = True + # block until stopped + while self._terminate_flag: + time.sleep(0.1)