-
Notifications
You must be signed in to change notification settings - Fork 4
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
[WIP] Text to speech mixin #405
base: master
Are you sure you want to change the base?
Changes from all commits
578184c
25ba45a
c18084b
5807d2c
e90062b
b8b010d
15052aa
eaaa7bd
e58fd52
66b89af
674fd56
acc2e50
b6ca019
30bc749
5a7c5c6
e7dd302
6646017
65ee6b0
c114265
2cd649a
8fc14a4
9614b99
ef0a566
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -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 | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,17 @@ | ||
from pitop import Pitop | ||
|
||
pitop = Pitop() | ||
|
||
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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,9 @@ | ||
from pitop.processing import tts | ||
|
||
config = { | ||
"language": "us", | ||
} | ||
|
||
speech = tts.services.get(service_id="FESTIVAL", **config) | ||
|
||
speech.say("This is the festival tts service") |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,22 @@ | ||
from pitop.processing import tts | ||
from pitop.processing.tts.services.tts_service import TTSService | ||
|
||
|
||
class SupportsSpeech: | ||
def __init__(self, service_id: str = "DEFAULT"): | ||
self._tts = tts.services.get(service_id=service_id) | ||
|
||
@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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1 @@ | ||
from .tts_service_provider import services |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,111 @@ | ||
import os | ||
from .tts_service import TTSService | ||
from threading import Thread | ||
from typing import Optional | ||
|
||
|
||
class FestivalBuilder: | ||
def __init__(self): | ||
self._instance = None | ||
|
||
def __call__(self, **kwargs): | ||
if self._instance is None: | ||
self._instance = FestivalService(**kwargs) | ||
return self._instance | ||
|
||
|
||
class FestivalService(TTSService): | ||
|
||
__VOICE_DIR = os.path.join(os.sep, "usr", "share", "festival", "voices") | ||
|
||
def __init__(self, language="us", **_ignored): | ||
self._speed = 1.0 | ||
self._available_voices = self.__get_available_voices() | ||
self._language = language | ||
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: | ||
if not self.__validate_request(text): | ||
return | ||
|
||
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.") | ||
|
||
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) | ||
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 available_voices(self) -> dict: | ||
return self._available_voices | ||
|
||
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. " | ||
f"Run display_voices() method to see what is available.") | ||
|
||
self._voice = voice | ||
self._language = language | ||
|
||
@property | ||
def voice(self): | ||
return self._voice | ||
|
||
@property | ||
def language(self): | ||
return self._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 |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,65 @@ | ||
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() |
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,12 @@ | ||
from .object_factory import ObjectFactory | ||
from .services.festival_service import FestivalBuilder | ||
|
||
|
||
class TTSServiceProvider(ObjectFactory): | ||
def get(self, service_id="DEFAULT", **kwargs): | ||
return self.create(service_id, **kwargs) | ||
|
||
|
||
services = TTSServiceProvider() | ||
services.register_builder("DEFAULT", FestivalBuilder()) | ||
services.register_builder("FESTIVAL", FestivalBuilder()) |
Original file line number | Diff line number | Diff line change | ||||
---|---|---|---|---|---|---|
|
@@ -137,6 +137,11 @@ | |||||
# Download Model Files # | ||||||
######################## | ||||||
"wget>=3.2,<4.0", | ||||||
|
||||||
################## | ||||||
# Text to Speech # | ||||||
################## | ||||||
"pt-pyfestival", | ||||||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This will not install correctly for people installing via Python on RPi for now, but we want tackle that later on. |
||||||
] | ||||||
|
||||||
__extra_requires__ = { | ||||||
|
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This is currently under
Replaces:
field, which is definitely not what you want to be doing.Are you trying to make this a core dependency, an optional dependency or a dependency of the full SDK?