Skip to content
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

Draft
wants to merge 23 commits into
base: master
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
578184c
Add wireframe for speech mixin
duwudi Jul 28, 2021
25ba45a
Remove device check
duwudi Jul 28, 2021
c18084b
Add example usage
duwudi Jul 28, 2021
5807d2c
Add language and voice configuration support
duwudi Jul 29, 2021
e90062b
Merge branch 'master' into tts
m-roberts Aug 10, 2021
b8b010d
Implement tts service factory
duwudi Aug 11, 2021
15052aa
Merge branch 'tts' of https://github.com/pi-top/pi-top-Python-SDK int…
duwudi Aug 11, 2021
eaaa7bd
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 11, 2021
e58fd52
Add non-blocking function with thread
duwudi Aug 11, 2021
66b89af
Merge branch 'tts' of https://github.com/pi-top/pi-top-Python-SDK int…
duwudi Aug 11, 2021
674fd56
Delete test file
duwudi Aug 11, 2021
acc2e50
Fix language parameter passing
duwudi Aug 11, 2021
b6ca019
Refactor
duwudi Aug 11, 2021
30bc749
Add classmethod for contructing Pitop object with different speech ba…
duwudi Aug 11, 2021
5a7c5c6
rename
duwudi Aug 11, 2021
e7dd302
Reorganise
duwudi Aug 11, 2021
6646017
[pre-commit.ci] auto fixes from pre-commit.com hooks
pre-commit-ci[bot] Aug 11, 2021
65ee6b0
Add default service ID
duwudi Aug 24, 2021
c114265
Add subprocess version to fix threading issue
duwudi Aug 25, 2021
2cd649a
Keep festival import inside thread to fix thread issue
duwudi Aug 25, 2021
8fc14a4
refactor say method
duwudi Aug 25, 2021
9614b99
Merge branch 'master' into tts
duwudi Aug 25, 2021
ef0a566
Add package requirements
duwudi Aug 25, 2021
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions debian/control
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,9 @@ Replaces:
pt-device-manager (<< 4.0.0),
# pt-oled
python3-pt-oled (<< 3.0.0),
# Festival speech engine
Copy link
Contributor

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?

festival,
festvox-us-slt-hts,
Description: pi-top Python 3 Library - Main
General purpose Python library for controlling a pi-top.
.
Expand Down
1 change: 1 addition & 0 deletions debian/py3dist-overrides
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
pyfestival python3-pt-pyfestival
pyfestival python3-pyfestival; PEP386

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PEP386 flag will make the dependency versioned, which we want in most cases.

17 changes: 17 additions & 0 deletions examples/system/pitop_speech.py
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)
9 changes: 9 additions & 0 deletions examples/system/tts_service.py
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")
1 change: 1 addition & 0 deletions pitop/core/mixins/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
from .stateful import Stateful
from .supports_battery import SupportsBattery
from .supports_miniscreen import SupportsMiniscreen
from .supports_speech import SupportsSpeech
22 changes: 22 additions & 0 deletions pitop/core/mixins/supports_speech.py
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
1 change: 1 addition & 0 deletions pitop/processing/tts/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from .tts_service_provider import services
12 changes: 12 additions & 0 deletions pitop/processing/tts/object_factory.py
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)
Empty file.
111 changes: 111 additions & 0 deletions pitop/processing/tts/services/festival_service.py
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
65 changes: 65 additions & 0 deletions pitop/processing/tts/services/tts_service.py
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()
12 changes: 12 additions & 0 deletions pitop/processing/tts/tts_service_provider.py
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())
4 changes: 3 additions & 1 deletion pitop/system/pitop.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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)
5 changes: 5 additions & 0 deletions setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,11 @@
# Download Model Files #
########################
"wget>=3.2,<4.0",

##################
# Text to Speech #
##################
"pt-pyfestival",
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
"pt-pyfestival",
"pyfestival",

Copy link
Contributor

Choose a reason for hiding this comment

The 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__ = {
Expand Down