From 578184cadd085184e9a90514121fed9213dd9f5e Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 28 Jul 2021 15:57:56 +0800 Subject: [PATCH 01/19] Add wireframe for speech mixin --- pitop/core/mixins/__init__.py | 1 + pitop/core/mixins/supports_speech.py | 19 +++++++++++++++++++ pitop/processing/speech/__init__.py | 1 + pitop/processing/speech/text_to_speech.py | 22 ++++++++++++++++++++++ pitop/system/pitop.py | 4 +++- 5 files changed, 46 insertions(+), 1 deletion(-) create mode 100644 pitop/core/mixins/supports_speech.py create mode 100644 pitop/processing/speech/__init__.py create mode 100644 pitop/processing/speech/text_to_speech.py diff --git a/pitop/core/mixins/__init__.py b/pitop/core/mixins/__init__.py index 76c47c239..70426c575 100644 --- a/pitop/core/mixins/__init__.py +++ b/pitop/core/mixins/__init__.py @@ -3,3 +3,4 @@ from .stateful import Stateful from .supports_battery import SupportsBattery from .supports_miniscreen import SupportsMiniscreen +from .supports_speech import SupportsSpeech diff --git a/pitop/core/mixins/supports_speech.py b/pitop/core/mixins/supports_speech.py new file mode 100644 index 000000000..f2e9bbe7f --- /dev/null +++ b/pitop/core/mixins/supports_speech.py @@ -0,0 +1,19 @@ +from pitop.core.exceptions import UnavailableComponent +from pitop.system import device_type + +from pitopcommon.common_names import DeviceName + + +class SupportsSpeech: + def __init__(self): + from pitop.processing.speech import TTS + self._tts = None + if device_type() == DeviceName.pi_top_4.value: + # TODO: probably unnecessary, can work on any device with a speaker + self._tts = TTS() + + @property + def speak(self): + if self._tts: + return self._tts + raise UnavailableComponent("Speech isn't available on this device") diff --git a/pitop/processing/speech/__init__.py b/pitop/processing/speech/__init__.py new file mode 100644 index 000000000..9a3c7a4a3 --- /dev/null +++ b/pitop/processing/speech/__init__.py @@ -0,0 +1 @@ +from .text_to_speech import TTS diff --git a/pitop/processing/speech/text_to_speech.py b/pitop/processing/speech/text_to_speech.py new file mode 100644 index 000000000..af2e2b2f9 --- /dev/null +++ b/pitop/processing/speech/text_to_speech.py @@ -0,0 +1,22 @@ +class TTS: + __VOICES = () + + def __init__(self): + self._voice = None + + def __call__(self, text: str): + print(text) + + @property + def voice(self): + return self._voice + + @voice.setter + def voice(self, voice: str): + if voice not in self.__VOICES: + raise ValueError(f"Invalid voice choice. " + f"Choose from {', '.join(self.__VOICES[:-1])} or {self.__VOICES[-1]}") + self._voice = voice + + def list_voices(self): + print(f"Available voices: f{self.__VOICES}") diff --git a/pitop/system/pitop.py b/pitop/system/pitop.py index c5e4357eb..f846aad8e 100644 --- a/pitop/system/pitop.py +++ b/pitop/system/pitop.py @@ -3,10 +3,11 @@ Componentable, SupportsBattery, SupportsMiniscreen, + SupportsSpeech, ) -class Pitop(SupportsMiniscreen, SupportsBattery, Componentable, metaclass=Singleton): +class Pitop(SupportsMiniscreen, SupportsBattery, SupportsSpeech, Componentable, metaclass=Singleton): """Represents a pi-top Device. When creating a `Pitop` object, multiple properties will be set, @@ -33,4 +34,5 @@ class in 2 different files, they will share the internal state. def __init__(self): SupportsMiniscreen.__init__(self) SupportsBattery.__init__(self) + SupportsSpeech.__init__(self) Componentable.__init__(self) From 25ba45a0a57afc7c0d3de1c9d079452f250903fc Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 28 Jul 2021 15:59:41 +0800 Subject: [PATCH 02/19] Remove device check Any raspberry pi with speaker connected will work --- pitop/core/mixins/supports_speech.py | 15 ++------------- 1 file changed, 2 insertions(+), 13 deletions(-) diff --git a/pitop/core/mixins/supports_speech.py b/pitop/core/mixins/supports_speech.py index f2e9bbe7f..d95fd5304 100644 --- a/pitop/core/mixins/supports_speech.py +++ b/pitop/core/mixins/supports_speech.py @@ -1,19 +1,8 @@ -from pitop.core.exceptions import UnavailableComponent -from pitop.system import device_type - -from pitopcommon.common_names import DeviceName - - class SupportsSpeech: def __init__(self): from pitop.processing.speech import TTS - self._tts = None - if device_type() == DeviceName.pi_top_4.value: - # TODO: probably unnecessary, can work on any device with a speaker - self._tts = TTS() + self._tts = TTS() @property def speak(self): - if self._tts: - return self._tts - raise UnavailableComponent("Speech isn't available on this device") + return self._tts From c18084ba209ce10cd8fe439509a8dbf82a67a135 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 28 Jul 2021 16:03:48 +0800 Subject: [PATCH 03/19] Add example usage --- examples/system/pitop_speech.py | 7 +++++++ 1 file changed, 7 insertions(+) create mode 100644 examples/system/pitop_speech.py diff --git a/examples/system/pitop_speech.py b/examples/system/pitop_speech.py new file mode 100644 index 000000000..bc41b35ec --- /dev/null +++ b/examples/system/pitop_speech.py @@ -0,0 +1,7 @@ +from pitop import Pitop + +pitop = Pitop() + +while True: + text = input("Enter text: ") + pitop.speak(text) From 5807d2cacd91163864dd68955debef4732c3e674 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Thu, 29 Jul 2021 17:51:04 +0800 Subject: [PATCH 04/19] Add language and voice configuration support --- pitop/processing/speech/text_to_speech.py | 68 ++++++++++++++++++++--- 1 file changed, 61 insertions(+), 7 deletions(-) diff --git a/pitop/processing/speech/text_to_speech.py b/pitop/processing/speech/text_to_speech.py index af2e2b2f9..c66e284a0 100644 --- a/pitop/processing/speech/text_to_speech.py +++ b/pitop/processing/speech/text_to_speech.py @@ -1,11 +1,30 @@ +import festival +import os + + class TTS: - __VOICES = () + __VOICE_DIR = os.path.join(os.sep, "usr", "share", "festival", "voices") def __init__(self): + self._speed = 1.0 + self.__voices = self.get_voices() + self._language = None + self.language = "us" self._voice = None + self.voice = self.__voices.get(self.language)[0] def __call__(self, text: str): - print(text) + festival.sayText(text) + + def get_voices(self): + languages = os.listdir(self.__VOICE_DIR) + language_dirs = (os.path.join(self.__VOICE_DIR, lang) for lang in languages) + + voice_dict = {} + for lang, lang_dir in zip(languages, language_dirs): + voice_dict[lang] = os.listdir(lang_dir) + + return voice_dict @property def voice(self): @@ -13,10 +32,45 @@ def voice(self): @voice.setter def voice(self, voice: str): - if voice not in self.__VOICES: - raise ValueError(f"Invalid voice choice. " - f"Choose from {', '.join(self.__VOICES[:-1])} or {self.__VOICES[-1]}") + available_voices = self.__voices.get(self.language) + if voice not in available_voices: + raise ValueError(f"Invalid voice choice. Please choose from:\n" + f"{available_voices}\n" + f"Or choose a different language. Run display_voices() method to see what is available.") self._voice = voice + success = festival.execCommand(f"(voice_{self._voice})") + if not success: + print("Changing voice failed.") + + @property + def language(self): + return self._language + + @language.setter + def language(self, language: str): + available_languages = list(self.__voices.keys()) + if language not in available_languages: + raise ValueError("Invalid language choice. Please choose from:\n" + f"{available_languages}") + self._language = language + + @property + def speed(self): + return self._speed + + @speed.setter + def speed(self, value: float): + if value < 0.2: + raise ValueError("Speed value must be greater than or equal to 0.2.") + + self._speed = value + success = festival.setStretchFactor(1 / self._speed) + + if not success: + print("Changing speed failed.") - def list_voices(self): - print(f"Available voices: f{self.__VOICES}") + def display_voices(self): + print("LANGUAGE VOICES") + for language, voices in self.__voices.items(): + print(f"{language:<10} {', '.join(voices)}") + print() From b8b010d51c794908d7625a8d953a9e96d31a5226 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 14:22:37 +0800 Subject: [PATCH 05/19] Implement tts service factory --- examples/metaclass_testing.py | 52 ++++++++++++++ examples/system/pitop_speech.py | 11 +++ pitop/core/mixins/supports_speech.py | 4 +- pitop/processing/speech/__init__.py | 2 +- pitop/processing/speech/object_factory.py | 12 ++++ pitop/processing/speech/services/__init__.py | 0 .../festival_service.py} | 68 +++++++++++-------- .../processing/speech/services/tts_service.py | 66 ++++++++++++++++++ pitop/processing/speech/tts.py | 12 ++++ 9 files changed, 195 insertions(+), 32 deletions(-) create mode 100644 examples/metaclass_testing.py create mode 100644 pitop/processing/speech/object_factory.py create mode 100644 pitop/processing/speech/services/__init__.py rename pitop/processing/speech/{text_to_speech.py => services/festival_service.py} (59%) create mode 100644 pitop/processing/speech/services/tts_service.py create mode 100644 pitop/processing/speech/tts.py diff --git a/examples/metaclass_testing.py b/examples/metaclass_testing.py new file mode 100644 index 000000000..26f33590e --- /dev/null +++ b/examples/metaclass_testing.py @@ -0,0 +1,52 @@ +class MetaUniverse(type): + def __new__(mcs, *args, **kwargs): + print("Metauniverse __new__") + print(args) + print(kwargs) + return super(MetaUniverse, mcs).__new__(mcs, *args, **kwargs) + + def __init__(cls, name, bases, dic): + print("Metauniverse __init__") + print(name) + print(bases) + print(dic) + super(MetaUniverse, cls).__init__(name, bases, dic) + + def __call__(cls, *args, **kwargs): + print("Metauniverse __call__") + print(args) + print(kwargs) + if kwargs.get("size") == 10: + print("SIZE IS 10") + return super(MetaUniverse, cls).__call__(*args, **kwargs) + + +class Base: + def __init__(self): + self.x = 10 + + def hello(self): + print("hello") + + +class Base2: + def __init__(self, value): + self.y = value + + +class World(Base, Base2, metaclass=MetaUniverse): + def __init__(self, size): + print("World init") + self.size = size + Base.__init__(self) + Base2.__init__(self, value=1) + + def get_name(self): + print("my name") + + +world = World(size=10) +print(world.x) +print(world.y) +world2 = World(size=20) +world3 = World(size=30) diff --git a/examples/system/pitop_speech.py b/examples/system/pitop_speech.py index bc41b35ec..e78b737c3 100644 --- a/examples/system/pitop_speech.py +++ b/examples/system/pitop_speech.py @@ -2,6 +2,17 @@ pitop = Pitop() +pitop.speak.speed = 0.25 +pitop.speak.print_voices() + +voices = pitop.speak.available_voices + +for language, voices in voices.items(): + for voice in voices: + pitop.speak.set_voice(language, voice) + print(f"LANGUAGE: {language}. VOICE: {voice}", flush=True) + pitop.speak("Hello") + while True: text = input("Enter text: ") pitop.speak(text) diff --git a/pitop/core/mixins/supports_speech.py b/pitop/core/mixins/supports_speech.py index d95fd5304..5e3615818 100644 --- a/pitop/core/mixins/supports_speech.py +++ b/pitop/core/mixins/supports_speech.py @@ -1,7 +1,7 @@ class SupportsSpeech: def __init__(self): - from pitop.processing.speech import TTS - self._tts = TTS() + from pitop.processing.speech import services + self._tts = services.get("DEFAULT") @property def speak(self): diff --git a/pitop/processing/speech/__init__.py b/pitop/processing/speech/__init__.py index 9a3c7a4a3..bb61fa6a8 100644 --- a/pitop/processing/speech/__init__.py +++ b/pitop/processing/speech/__init__.py @@ -1 +1 @@ -from .text_to_speech import TTS +from .tts import services diff --git a/pitop/processing/speech/object_factory.py b/pitop/processing/speech/object_factory.py new file mode 100644 index 000000000..e7a876ffb --- /dev/null +++ b/pitop/processing/speech/object_factory.py @@ -0,0 +1,12 @@ +class ObjectFactory: + def __init__(self): + self._builders = {} + + def register_builder(self, key, builder): + self._builders[key] = builder + + def create(self, key, **kwargs): + builder = self._builders.get(key) + if not builder: + raise ValueError(key) + return builder(**kwargs) diff --git a/pitop/processing/speech/services/__init__.py b/pitop/processing/speech/services/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/pitop/processing/speech/text_to_speech.py b/pitop/processing/speech/services/festival_service.py similarity index 59% rename from pitop/processing/speech/text_to_speech.py rename to pitop/processing/speech/services/festival_service.py index c66e284a0..7afc8a340 100644 --- a/pitop/processing/speech/text_to_speech.py +++ b/pitop/processing/speech/services/festival_service.py @@ -1,22 +1,37 @@ -import festival import os +import festival +from processing.speech.services.tts_service import TTSService + + +class FestivalBuilder: + def __init__(self): + self._instance = None + + def __call__(self, **ignored): + if self._instance is None: + self._instance = FestivalService() + return self._instance -class TTS: +class FestivalService(TTSService): + __VOICE_DIR = os.path.join(os.sep, "usr", "share", "festival", "voices") def __init__(self): self._speed = 1.0 - self.__voices = self.get_voices() - self._language = None - self.language = "us" - self._voice = None - self.voice = self.__voices.get(self.language)[0] + self._available_voices = self.__get_available_voices() + self._language = "us" + self._voice = self._available_voices.get(self.language)[0] + self.set_voice(self._language, self._voice) + + def __call__(self, text: str, blocking: bool = True): + self.say(text=text, blocking=blocking) - def __call__(self, text: str): - festival.sayText(text) + def say(self, text: str, blocking: bool = True) -> None: + if blocking: + festival.sayText(text) - def get_voices(self): + def __get_available_voices(self): languages = os.listdir(self.__VOICE_DIR) language_dirs = (os.path.join(self.__VOICE_DIR, lang) for lang in languages) @@ -27,33 +42,34 @@ def get_voices(self): return voice_dict @property - def voice(self): - return self._voice + def available_voices(self) -> dict: + return self._available_voices + + def set_voice(self, language: str, voice: str) -> None: + available_languages = list(self._available_voices.keys()) + if language not in available_languages: + raise ValueError("Invalid language choice. Please choose from:\n" + f"{available_languages}") - @voice.setter - def voice(self, voice: str): - available_voices = self.__voices.get(self.language) + available_voices = self._available_voices.get(language) if voice not in available_voices: raise ValueError(f"Invalid voice choice. Please choose from:\n" f"{available_voices}\n" f"Or choose a different language. Run display_voices() method to see what is available.") + self._voice = voice success = festival.execCommand(f"(voice_{self._voice})") if not success: print("Changing voice failed.") + @property + def voice(self): + return self._voice + @property def language(self): return self._language - @language.setter - def language(self, language: str): - available_languages = list(self.__voices.keys()) - if language not in available_languages: - raise ValueError("Invalid language choice. Please choose from:\n" - f"{available_languages}") - self._language = language - @property def speed(self): return self._speed @@ -68,9 +84,3 @@ def speed(self, value: float): if not success: print("Changing speed failed.") - - def display_voices(self): - print("LANGUAGE VOICES") - for language, voices in self.__voices.items(): - print(f"{language:<10} {', '.join(voices)}") - print() diff --git a/pitop/processing/speech/services/tts_service.py b/pitop/processing/speech/services/tts_service.py new file mode 100644 index 000000000..fca320ac5 --- /dev/null +++ b/pitop/processing/speech/services/tts_service.py @@ -0,0 +1,66 @@ +from abc import ( + ABCMeta, + abstractmethod, +) + + +class AttributeMeta(ABCMeta): + """ + Metaclass for ensuring children of abstract base class define specified attributes. + """ + required_attributes = [] + + def __call__(cls, *args, **kwargs): + obj = super(AttributeMeta, cls).__call__(*args, **kwargs) + for attr_name in obj.required_attributes: + if not getattr(obj, attr_name): + raise ValueError('required attribute (%s) not set' % attr_name) + return obj + + +class TTSService(metaclass=AttributeMeta): + required_attributes = ['_voice', '_available_voices', '_language', '_speed'] + + @abstractmethod + def __call__(self, text: str, blocking: bool = True) -> None: + pass + + @abstractmethod + def say(self, text: str, blocking: bool = True) -> None: + pass + + @property + @abstractmethod + def available_voices(self) -> dict: + pass + + @abstractmethod + def set_voice(self, language: str, name: str) -> None: + pass + + @property + @abstractmethod + def voice(self) -> str: + pass + + @property + @abstractmethod + def language(self) -> str: + pass + + @property + @abstractmethod + def speed(self): + pass + + @speed.setter + @abstractmethod + def speed(self, speed: float): + pass + + def print_voices(self): + print("LANGUAGE VOICES") + print("-------- ------") + for language, voices in self.available_voices.items(): + print(f"{language:<10} {', '.join(voices)}") + print() diff --git a/pitop/processing/speech/tts.py b/pitop/processing/speech/tts.py new file mode 100644 index 000000000..281e87b15 --- /dev/null +++ b/pitop/processing/speech/tts.py @@ -0,0 +1,12 @@ +from .object_factory import ObjectFactory +from processing.speech.services.festival_service import FestivalBuilder + + +class TTSServiceProvider(ObjectFactory): + def get(self, service_id, **kwargs): + return self.create(service_id, **kwargs) + + +services = TTSServiceProvider() +services.register_builder("DEFAULT", FestivalBuilder()) +services.register_builder("FESTIVAL", FestivalBuilder()) From eaaa7bd29e3344ef4fe4bbaf7574f4be7f6d5bc7 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Aug 2021 06:23:15 +0000 Subject: [PATCH 06/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pitop/processing/speech/services/tts_service.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/pitop/processing/speech/services/tts_service.py b/pitop/processing/speech/services/tts_service.py index fca320ac5..a81462f49 100644 --- a/pitop/processing/speech/services/tts_service.py +++ b/pitop/processing/speech/services/tts_service.py @@ -5,9 +5,8 @@ class AttributeMeta(ABCMeta): - """ - Metaclass for ensuring children of abstract base class define specified attributes. - """ + """Metaclass for ensuring children of abstract base class define specified + attributes.""" required_attributes = [] def __call__(cls, *args, **kwargs): From e58fd527fe5df6575de37250b639c477cda3cc88 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 15:01:46 +0800 Subject: [PATCH 07/19] Add non-blocking function with thread --- examples/system/pitop_speech.py | 1 + .../speech/services/festival_service.py | 23 ++++++++++++++++++- pitop/processing/speech/tts.py | 2 +- 3 files changed, 24 insertions(+), 2 deletions(-) diff --git a/examples/system/pitop_speech.py b/examples/system/pitop_speech.py index e78b737c3..7994d5e46 100644 --- a/examples/system/pitop_speech.py +++ b/examples/system/pitop_speech.py @@ -7,6 +7,7 @@ voices = pitop.speak.available_voices + for language, voices in voices.items(): for voice in voices: pitop.speak.set_voice(language, voice) diff --git a/pitop/processing/speech/services/festival_service.py b/pitop/processing/speech/services/festival_service.py index 7afc8a340..e7c5ec189 100644 --- a/pitop/processing/speech/services/festival_service.py +++ b/pitop/processing/speech/services/festival_service.py @@ -1,6 +1,7 @@ import os import festival from processing.speech.services.tts_service import TTSService +from threading import Thread class FestivalBuilder: @@ -23,13 +24,33 @@ def __init__(self): self._language = "us" self._voice = self._available_voices.get(self.language)[0] self.set_voice(self._language, self._voice) + self._say_thread = Thread() def __call__(self, text: str, blocking: bool = True): self.say(text=text, blocking=blocking) def say(self, text: str, blocking: bool = True) -> None: + def sayText(_text): + festival.sayText(_text) + + if not self.__validate_request(text): + return + if blocking: - festival.sayText(text) + sayText(text) + else: + self._say_thread = Thread(target=sayText, args=(text,), daemon=True) + self._say_thread.start() + + def __validate_request(self, text): + if text == "" or type(text) != str: + raise ValueError("Text must be a string and cannot be empty.") + + if self._say_thread.is_alive(): + print("Speech already in progress, request cancelled.") + return False + + return True def __get_available_voices(self): languages = os.listdir(self.__VOICE_DIR) diff --git a/pitop/processing/speech/tts.py b/pitop/processing/speech/tts.py index 281e87b15..1c749a99c 100644 --- a/pitop/processing/speech/tts.py +++ b/pitop/processing/speech/tts.py @@ -1,5 +1,5 @@ from .object_factory import ObjectFactory -from processing.speech.services.festival_service import FestivalBuilder +from .services.festival_service import FestivalBuilder class TTSServiceProvider(ObjectFactory): From 674fd56b05cbfd9f14731951930ba897383e41e0 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 15:06:04 +0800 Subject: [PATCH 08/19] Delete test file --- examples/metaclass_testing.py | 52 ----------------------------------- 1 file changed, 52 deletions(-) delete mode 100644 examples/metaclass_testing.py diff --git a/examples/metaclass_testing.py b/examples/metaclass_testing.py deleted file mode 100644 index 26f33590e..000000000 --- a/examples/metaclass_testing.py +++ /dev/null @@ -1,52 +0,0 @@ -class MetaUniverse(type): - def __new__(mcs, *args, **kwargs): - print("Metauniverse __new__") - print(args) - print(kwargs) - return super(MetaUniverse, mcs).__new__(mcs, *args, **kwargs) - - def __init__(cls, name, bases, dic): - print("Metauniverse __init__") - print(name) - print(bases) - print(dic) - super(MetaUniverse, cls).__init__(name, bases, dic) - - def __call__(cls, *args, **kwargs): - print("Metauniverse __call__") - print(args) - print(kwargs) - if kwargs.get("size") == 10: - print("SIZE IS 10") - return super(MetaUniverse, cls).__call__(*args, **kwargs) - - -class Base: - def __init__(self): - self.x = 10 - - def hello(self): - print("hello") - - -class Base2: - def __init__(self, value): - self.y = value - - -class World(Base, Base2, metaclass=MetaUniverse): - def __init__(self, size): - print("World init") - self.size = size - Base.__init__(self) - Base2.__init__(self, value=1) - - def get_name(self): - print("my name") - - -world = World(size=10) -print(world.x) -print(world.y) -world2 = World(size=20) -world3 = World(size=30) From acc2e50304ec1a135a30cd6e6079a77ab131a5ae Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 16:35:57 +0800 Subject: [PATCH 09/19] Fix language parameter passing --- examples/system/pitop_speech.py | 4 +--- examples/system/tts_service.py | 9 +++++++++ pitop/processing/speech/services/festival_service.py | 10 +++++----- 3 files changed, 15 insertions(+), 8 deletions(-) create mode 100644 examples/system/tts_service.py diff --git a/examples/system/pitop_speech.py b/examples/system/pitop_speech.py index 7994d5e46..83aa47b7e 100644 --- a/examples/system/pitop_speech.py +++ b/examples/system/pitop_speech.py @@ -2,16 +2,14 @@ pitop = Pitop() -pitop.speak.speed = 0.25 pitop.speak.print_voices() voices = pitop.speak.available_voices - for language, voices in voices.items(): for voice in voices: pitop.speak.set_voice(language, voice) - print(f"LANGUAGE: {language}. VOICE: {voice}", flush=True) + print(f"LANGUAGE: {language} | VOICE: {voice}", flush=True) pitop.speak("Hello") while True: diff --git a/examples/system/tts_service.py b/examples/system/tts_service.py new file mode 100644 index 000000000..a4cb8e352 --- /dev/null +++ b/examples/system/tts_service.py @@ -0,0 +1,9 @@ +from pitop.processing.speech import services + +config = { + "language": "us", +} + +tts = services.get(service_id="FESTIVAL", **config) + +tts.say("This is the festival speech service") diff --git a/pitop/processing/speech/services/festival_service.py b/pitop/processing/speech/services/festival_service.py index e7c5ec189..d64a1c61d 100644 --- a/pitop/processing/speech/services/festival_service.py +++ b/pitop/processing/speech/services/festival_service.py @@ -1,6 +1,6 @@ import os import festival -from processing.speech.services.tts_service import TTSService +from .tts_service import TTSService from threading import Thread @@ -8,9 +8,9 @@ class FestivalBuilder: def __init__(self): self._instance = None - def __call__(self, **ignored): + def __call__(self, language, **ignored): if self._instance is None: - self._instance = FestivalService() + self._instance = FestivalService(language=language) return self._instance @@ -18,10 +18,10 @@ class FestivalService(TTSService): __VOICE_DIR = os.path.join(os.sep, "usr", "share", "festival", "voices") - def __init__(self): + def __init__(self, language="us"): self._speed = 1.0 self._available_voices = self.__get_available_voices() - self._language = "us" + self._language = language self._voice = self._available_voices.get(self.language)[0] self.set_voice(self._language, self._voice) self._say_thread = Thread() From b6ca01910e80846332aa33ee5c5f77b2c6908f8f Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 17:35:44 +0800 Subject: [PATCH 10/19] Refactor --- examples/system/tts_service.py | 6 +++--- pitop/core/mixins/supports_speech.py | 8 +++++--- pitop/processing/speech/__init__.py | 1 - pitop/processing/tts/__init__.py | 1 + pitop/processing/{speech => tts}/object_factory.py | 0 pitop/processing/{speech => tts}/services/__init__.py | 0 .../{speech => tts}/services/festival_service.py | 6 +++--- pitop/processing/{speech => tts}/services/tts_service.py | 0 .../{speech/tts.py => tts/tts_service_provider.py} | 0 9 files changed, 12 insertions(+), 10 deletions(-) delete mode 100644 pitop/processing/speech/__init__.py create mode 100644 pitop/processing/tts/__init__.py rename pitop/processing/{speech => tts}/object_factory.py (100%) rename pitop/processing/{speech => tts}/services/__init__.py (100%) rename pitop/processing/{speech => tts}/services/festival_service.py (95%) rename pitop/processing/{speech => tts}/services/tts_service.py (100%) rename pitop/processing/{speech/tts.py => tts/tts_service_provider.py} (100%) diff --git a/examples/system/tts_service.py b/examples/system/tts_service.py index a4cb8e352..8c76e47d5 100644 --- a/examples/system/tts_service.py +++ b/examples/system/tts_service.py @@ -1,9 +1,9 @@ -from pitop.processing.speech import services +from pitop.processing import tts config = { "language": "us", } -tts = services.get(service_id="FESTIVAL", **config) +tts = tts.services.get(service_id="FESTIVAL", **config) -tts.say("This is the festival speech service") +tts.say("This is the festival tts service") diff --git a/pitop/core/mixins/supports_speech.py b/pitop/core/mixins/supports_speech.py index 5e3615818..8b944a71d 100644 --- a/pitop/core/mixins/supports_speech.py +++ b/pitop/core/mixins/supports_speech.py @@ -1,7 +1,9 @@ +from pitop.processing import tts + + class SupportsSpeech: - def __init__(self): - from pitop.processing.speech import services - self._tts = services.get("DEFAULT") + def __init__(self, service_id: str = "DEFAULT"): + self._tts = tts.services.get(service_id=service_id) @property def speak(self): diff --git a/pitop/processing/speech/__init__.py b/pitop/processing/speech/__init__.py deleted file mode 100644 index bb61fa6a8..000000000 --- a/pitop/processing/speech/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from .tts import services diff --git a/pitop/processing/tts/__init__.py b/pitop/processing/tts/__init__.py new file mode 100644 index 000000000..ed25933f1 --- /dev/null +++ b/pitop/processing/tts/__init__.py @@ -0,0 +1 @@ +from .tts_service_provider import services diff --git a/pitop/processing/speech/object_factory.py b/pitop/processing/tts/object_factory.py similarity index 100% rename from pitop/processing/speech/object_factory.py rename to pitop/processing/tts/object_factory.py diff --git a/pitop/processing/speech/services/__init__.py b/pitop/processing/tts/services/__init__.py similarity index 100% rename from pitop/processing/speech/services/__init__.py rename to pitop/processing/tts/services/__init__.py diff --git a/pitop/processing/speech/services/festival_service.py b/pitop/processing/tts/services/festival_service.py similarity index 95% rename from pitop/processing/speech/services/festival_service.py rename to pitop/processing/tts/services/festival_service.py index d64a1c61d..80c2da968 100644 --- a/pitop/processing/speech/services/festival_service.py +++ b/pitop/processing/tts/services/festival_service.py @@ -8,9 +8,9 @@ class FestivalBuilder: def __init__(self): self._instance = None - def __call__(self, language, **ignored): + def __call__(self, **kwargs): if self._instance is None: - self._instance = FestivalService(language=language) + self._instance = FestivalService(**kwargs) return self._instance @@ -18,7 +18,7 @@ class FestivalService(TTSService): __VOICE_DIR = os.path.join(os.sep, "usr", "share", "festival", "voices") - def __init__(self, language="us"): + def __init__(self, language="us", **_ignored): self._speed = 1.0 self._available_voices = self.__get_available_voices() self._language = language diff --git a/pitop/processing/speech/services/tts_service.py b/pitop/processing/tts/services/tts_service.py similarity index 100% rename from pitop/processing/speech/services/tts_service.py rename to pitop/processing/tts/services/tts_service.py diff --git a/pitop/processing/speech/tts.py b/pitop/processing/tts/tts_service_provider.py similarity index 100% rename from pitop/processing/speech/tts.py rename to pitop/processing/tts/tts_service_provider.py From 30bc74961d6d993e1c5d92cf430aa27f9d64927d Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 18:36:40 +0800 Subject: [PATCH 11/19] Add classmethod for contructing Pitop object with different speech backend --- pitop/core/mixins/supports_speech.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/pitop/core/mixins/supports_speech.py b/pitop/core/mixins/supports_speech.py index 8b944a71d..3b0e28538 100644 --- a/pitop/core/mixins/supports_speech.py +++ b/pitop/core/mixins/supports_speech.py @@ -1,4 +1,5 @@ from pitop.processing import tts +from pitop.processing.tts.services.tts_service import TTSService class SupportsSpeech: @@ -8,3 +9,14 @@ def __init__(self, service_id: str = "DEFAULT"): @property def speak(self): return self._tts + + @speak.setter + def speak(self, service: TTSService): + self._tts = service + + @classmethod + def using_speech_service(cls, service_id): + obj = cls() + speech_service = tts.services.get(service_id=service_id) + obj.speak = speech_service + return obj From 5a7c5c6f4058b96d5f95c88919efaf0fb8ae93b4 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 18:36:47 +0800 Subject: [PATCH 12/19] rename --- examples/system/tts_service.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/examples/system/tts_service.py b/examples/system/tts_service.py index 8c76e47d5..87902f95b 100644 --- a/examples/system/tts_service.py +++ b/examples/system/tts_service.py @@ -4,6 +4,6 @@ "language": "us", } -tts = tts.services.get(service_id="FESTIVAL", **config) +speech = tts.services.get(service_id="FESTIVAL", **config) -tts.say("This is the festival tts service") +speech.say("This is the festival tts service") From e7dd30271f8b870b163e0622e98349a6491c8bba Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 11 Aug 2021 20:16:47 +0800 Subject: [PATCH 13/19] Reorganise --- .../tts/services/festival_service.py | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/pitop/processing/tts/services/festival_service.py b/pitop/processing/tts/services/festival_service.py index 80c2da968..be66bf210 100644 --- a/pitop/processing/tts/services/festival_service.py +++ b/pitop/processing/tts/services/festival_service.py @@ -1,8 +1,9 @@ import os -import festival from .tts_service import TTSService from threading import Thread - +from typing import Optional +import festival +# from importlib import reload class FestivalBuilder: def __init__(self): @@ -66,21 +67,28 @@ def __get_available_voices(self): def available_voices(self) -> dict: return self._available_voices - def set_voice(self, language: str, voice: str) -> None: + def set_voice(self, language: str, voice: Optional[str] = None) -> None: available_languages = list(self._available_voices.keys()) if language not in available_languages: raise ValueError("Invalid language choice. Please choose from:\n" f"{available_languages}") available_voices = self._available_voices.get(language) + + voice = available_voices[0] if voice is None else voice + if voice not in available_voices: raise ValueError(f"Invalid voice choice. Please choose from:\n" f"{available_voices}\n" - f"Or choose a different language. Run display_voices() method to see what is available.") + f"Or choose a different language. " + f"Run display_voices() method to see what is available.") - self._voice = voice - success = festival.execCommand(f"(voice_{self._voice})") - if not success: + success = festival.execCommand(f"(voice_{voice})") + + if success: + self._voice = voice + self._language = language + else: print("Changing voice failed.") @property From 6646017b4a9a07a096d0b7628ddd3b79d2aa9d37 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Wed, 11 Aug 2021 12:17:18 +0000 Subject: [PATCH 14/19] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- pitop/processing/tts/services/festival_service.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pitop/processing/tts/services/festival_service.py b/pitop/processing/tts/services/festival_service.py index be66bf210..6640d5707 100644 --- a/pitop/processing/tts/services/festival_service.py +++ b/pitop/processing/tts/services/festival_service.py @@ -5,6 +5,7 @@ import festival # from importlib import reload + class FestivalBuilder: def __init__(self): self._instance = None From 65ee6b0487d7f5bbc9d4390b797d5bd87d5e2a11 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Tue, 24 Aug 2021 11:16:36 +0800 Subject: [PATCH 15/19] Add default service ID --- pitop/processing/tts/tts_service_provider.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pitop/processing/tts/tts_service_provider.py b/pitop/processing/tts/tts_service_provider.py index 1c749a99c..10b0b7ea2 100644 --- a/pitop/processing/tts/tts_service_provider.py +++ b/pitop/processing/tts/tts_service_provider.py @@ -3,7 +3,7 @@ class TTSServiceProvider(ObjectFactory): - def get(self, service_id, **kwargs): + def get(self, service_id="DEFAULT", **kwargs): return self.create(service_id, **kwargs) From c114265b157855df33b762cc038d96720695d308 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 25 Aug 2021 10:50:19 +0800 Subject: [PATCH 16/19] Add subprocess version to fix threading issue --- .../tts/services/festival_service.py | 21 +++++++++++-------- 1 file changed, 12 insertions(+), 9 deletions(-) diff --git a/pitop/processing/tts/services/festival_service.py b/pitop/processing/tts/services/festival_service.py index 6640d5707..00829da28 100644 --- a/pitop/processing/tts/services/festival_service.py +++ b/pitop/processing/tts/services/festival_service.py @@ -1,9 +1,8 @@ import os from .tts_service import TTSService -from threading import Thread from typing import Optional import festival -# from importlib import reload +from subprocess import Popen class FestivalBuilder: @@ -26,7 +25,7 @@ def __init__(self, language="us", **_ignored): self._language = language self._voice = self._available_voices.get(self.language)[0] self.set_voice(self._language, self._voice) - self._say_thread = Thread() + self._say_subprocess = None def __call__(self, text: str, blocking: bool = True): self.say(text=text, blocking=blocking) @@ -41,14 +40,17 @@ def sayText(_text): if blocking: sayText(text) else: - self._say_thread = Thread(target=sayText, args=(text,), daemon=True) - self._say_thread.start() + # Festival python lib not thread safe, have to use subprocess until a solution is found. + self._say_subprocess = Popen(f"festival -b '(voice_{self.voice})' '' '(SayText \"{text}\")'", shell=True) def __validate_request(self, text): if text == "" or type(text) != str: raise ValueError("Text must be a string and cannot be empty.") - if self._say_thread.is_alive(): + if self._say_subprocess is None: + return True + + if self._say_subprocess.poll() is None: print("Speech already in progress, request cancelled.") return False @@ -109,8 +111,9 @@ def speed(self, value: float): if value < 0.2: raise ValueError("Speed value must be greater than or equal to 0.2.") - self._speed = value - success = festival.setStretchFactor(1 / self._speed) + success = festival.setStretchFactor(1 / value) - if not success: + if success: + self._speed = value + else: print("Changing speed failed.") From 2cd649ae087267c58809fceb6d18d525b7fea22e Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 25 Aug 2021 10:58:39 +0800 Subject: [PATCH 17/19] Keep festival import inside thread to fix thread issue --- .../tts/services/festival_service.py | 36 +++++++------------ 1 file changed, 13 insertions(+), 23 deletions(-) diff --git a/pitop/processing/tts/services/festival_service.py b/pitop/processing/tts/services/festival_service.py index 00829da28..925cbe412 100644 --- a/pitop/processing/tts/services/festival_service.py +++ b/pitop/processing/tts/services/festival_service.py @@ -1,8 +1,7 @@ import os from .tts_service import TTSService +from threading import Thread from typing import Optional -import festival -from subprocess import Popen class FestivalBuilder: @@ -26,31 +25,32 @@ def __init__(self, language="us", **_ignored): self._voice = self._available_voices.get(self.language)[0] self.set_voice(self._language, self._voice) self._say_subprocess = None + self._say_thread = Thread() def __call__(self, text: str, blocking: bool = True): self.say(text=text, blocking=blocking) def say(self, text: str, blocking: bool = True) -> None: def sayText(_text): + import festival + festival.execCommand(f"(voice_{self.voice})") + festival.setStretchFactor(1 / self.speed) festival.sayText(_text) if not self.__validate_request(text): return + self._say_thread = Thread(target=sayText, args=(text,), daemon=True) + self._say_thread.start() + if blocking: - sayText(text) - else: - # Festival python lib not thread safe, have to use subprocess until a solution is found. - self._say_subprocess = Popen(f"festival -b '(voice_{self.voice})' '' '(SayText \"{text}\")'", shell=True) + self._say_thread.join() def __validate_request(self, text): if text == "" or type(text) != str: raise ValueError("Text must be a string and cannot be empty.") - if self._say_subprocess is None: - return True - - if self._say_subprocess.poll() is None: + if self._say_thread.is_alive(): print("Speech already in progress, request cancelled.") return False @@ -86,13 +86,8 @@ def set_voice(self, language: str, voice: Optional[str] = None) -> None: f"Or choose a different language. " f"Run display_voices() method to see what is available.") - success = festival.execCommand(f"(voice_{voice})") - - if success: - self._voice = voice - self._language = language - else: - print("Changing voice failed.") + self._voice = voice + self._language = language @property def voice(self): @@ -111,9 +106,4 @@ def speed(self, value: float): if value < 0.2: raise ValueError("Speed value must be greater than or equal to 0.2.") - success = festival.setStretchFactor(1 / value) - - if success: - self._speed = value - else: - print("Changing speed failed.") + self._speed = value From 8fc14a4efbbc842f492858bd4e3798b800fb001a Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 25 Aug 2021 11:07:46 +0800 Subject: [PATCH 18/19] refactor say method --- .../processing/tts/services/festival_service.py | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/pitop/processing/tts/services/festival_service.py b/pitop/processing/tts/services/festival_service.py index 925cbe412..7592f8ff0 100644 --- a/pitop/processing/tts/services/festival_service.py +++ b/pitop/processing/tts/services/festival_service.py @@ -31,21 +31,23 @@ def __call__(self, text: str, blocking: bool = True): self.say(text=text, blocking=blocking) def say(self, text: str, blocking: bool = True) -> None: - def sayText(_text): - import festival - festival.execCommand(f"(voice_{self.voice})") - festival.setStretchFactor(1 / self.speed) - festival.sayText(_text) - if not self.__validate_request(text): return - self._say_thread = Thread(target=sayText, args=(text,), daemon=True) + self._say_thread = Thread(target=self.__festival_say_commands, args=(text,), daemon=True) self._say_thread.start() if blocking: self._say_thread.join() + def __festival_say_commands(self, text): + # Have to import festival within the thread since it doesn't work with multi-threading + # As a result, have to set voice and stretch factor with every call + import festival + festival.execCommand(f"(voice_{self.voice})") + festival.setStretchFactor(1 / self.speed) + festival.sayText(text) + def __validate_request(self, text): if text == "" or type(text) != str: raise ValueError("Text must be a string and cannot be empty.") From ef0a566c50adc0c5a21167d5c1a8882b79b4d627 Mon Sep 17 00:00:00 2001 From: Ryan Dunwoody Date: Wed, 25 Aug 2021 11:32:34 +0800 Subject: [PATCH 19/19] Add package requirements --- debian/control | 3 +++ debian/py3dist-overrides | 1 + setup.py | 5 +++++ 3 files changed, 9 insertions(+) diff --git a/debian/control b/debian/control index ef418e401..1f8f798e8 100644 --- a/debian/control +++ b/debian/control @@ -82,6 +82,9 @@ Replaces: pt-device-manager (<< 4.0.0), # pt-oled python3-pt-oled (<< 3.0.0), +# Festival speech engine + festival, + festvox-us-slt-hts, Description: pi-top Python 3 Library - Main General purpose Python library for controlling a pi-top. . diff --git a/debian/py3dist-overrides b/debian/py3dist-overrides index d79d8b8f9..e05c92494 100644 --- a/debian/py3dist-overrides +++ b/debian/py3dist-overrides @@ -21,3 +21,4 @@ spidev python3-spidev; PEP386 systemd_python python3-systemd; PEP386 wget python3-wget; PEP386 pyzmq python3-zmq; PEP386 +pyfestival python3-pt-pyfestival diff --git a/setup.py b/setup.py index c1a3047a4..cd8133dcd 100644 --- a/setup.py +++ b/setup.py @@ -137,6 +137,11 @@ # Download Model Files # ######################## "wget>=3.2,<4.0", + + ################## + # Text to Speech # + ################## + "pt-pyfestival", ] __extra_requires__ = {