From bfdc26621e00771dd953d9bf8f6a2605f712558e Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 25 Mar 2022 11:52:23 -0400 Subject: [PATCH] Clean up opentts_abc --- opentts-abc/README.md | 4 + opentts-abc/opentts_abc/__init__.py | 135 +++++++++--- opentts-abc/opentts_abc/ssml.py | 319 +++++++++++++++------------- opentts-abc/pylintrc | 39 ++++ 4 files changed, 318 insertions(+), 179 deletions(-) create mode 100644 opentts-abc/pylintrc diff --git a/opentts-abc/README.md b/opentts-abc/README.md index e69de29..76476b9 100644 --- a/opentts-abc/README.md +++ b/opentts-abc/README.md @@ -0,0 +1,4 @@ +# Open Text to Speech (TTS) Abstract Base Classes (ABC) + +Base classes for open text to speech systems. + diff --git a/opentts-abc/opentts_abc/__init__.py b/opentts-abc/opentts_abc/__init__.py index d19ffeb..1ebed4f 100644 --- a/opentts-abc/opentts_abc/__init__.py +++ b/opentts-abc/opentts_abc/__init__.py @@ -1,68 +1,107 @@ #!/usr/bin/env python3 """Base classes for Open Text to Speech systems""" -import dataclasses import io import typing import wave from abc import ABCMeta, abstractmethod from contextlib import AbstractContextManager -from copy import deepcopy from dataclasses import dataclass @dataclass class Settings: + """Current settings for TTS system""" + voice: typing.Optional[str] = None + """Current voice key""" + language: typing.Optional[str] = None + """Current language (e.g., en_US)""" + volume: typing.Optional[float] = None + """Current speaking volume""" + rate: typing.Optional[float] = None + """Current speaking rate""" + pitch: typing.Optional[float] = None - active_lexicons: typing.Optional[typing.Sequence[str]] = None + """Current speaking pitch""" + other_settings: typing.Optional[typing.Mapping[str, typing.Any]] = None + """Custom settings""" @dataclass class BaseToken(metaclass=ABCMeta): + """Base class for spoken tokens""" + text: str + """Text of the token""" @dataclass class Word(BaseToken): + """Token representing a single word""" + role: typing.Optional[str] = None + """Role of the word (typically part of speech)""" @dataclass class Phonemes(BaseToken): + """Token representing a phonemized word""" + alphabet: typing.Optional[str] = None + """Phoneme alphabet (e.g., ipa)""" @dataclass class SayAs(BaseToken): + """Token representing a word or phrase that must be spoken a particular way""" + interpret_as: str + """Implementation-dependent token interpretation (e.g., characters or digits)""" + format: typing.Optional[str] = None + """Implementation-dependent token format (depends on interpret_as)""" @dataclass class _BaseResultDefaults: + """Base class of results from TTS end_utterance""" + tag: typing.Optional[typing.Any] = None + """Optional tag to associate with results""" @dataclass class BaseResult(metaclass=ABCMeta): - pass + """Base class of results from TTS end_utterance""" @dataclass class _AudioResultBase: + """Synthesized audio result""" + sample_rate_hz: int + """Sample rate in Hertz (e.g., 22050)""" + sample_width_bytes: int + """Sample width in bytes (e.g., 2)""" + num_channels: int + """Number of audio channels (e.g., 1)""" + audio_bytes: bytes + """Raw audio bytes (no header)""" @dataclass class AudioResult(BaseResult, _BaseResultDefaults, _AudioResultBase): + """Synthesized audio result""" + def to_wav_bytes(self) -> bytes: + """Convert audio bytes to WAV""" with io.BytesIO() as wav_io: wav_file: wave.Wave_write = wave.open(wav_io, "wb") with wav_file: @@ -76,92 +115,126 @@ class AudioResult(BaseResult, _BaseResultDefaults, _AudioResultBase): @dataclass class _MarkResultBase: + """Result indicating a has been reached in SSML""" + name: str + """Name of the """ @dataclass class MarkResult(BaseResult, _BaseResultDefaults, _MarkResultBase): - pass + """Result indicating a has been reached in SSML""" @dataclass class Voice: + """Details of a voice in a text to speech system""" + key: str + """Unique key that can be used to reference the voice""" + name: str + """Human-readable name of the voice""" + language: str + """Language of the voice (e.g., en_US)""" + description: str + """Human-readable description of the voice""" + speakers: typing.Optional[typing.Sequence[str]] = None + """List of speakers within the voice model if multi-speaker""" + properties: typing.Optional[typing.Mapping[str, typing.Any]] = None + """Additional properties associated with the voice""" @property def is_multispeaker(self) -> bool: + """True if voice has multiple speakers""" return (self.speakers is not None) and (len(self.speakers) > 1) -# @dataclass -# class LexiconEntry: -# word: str -# pronunciation: str -# role: typing.Optional[str] = None - - -# @dataclass -# class Lexicon: -# name: str -# entries: typing.Mapping[str, typing.Sequence[LexiconEntry]] - - class TextToSpeechSystem(AbstractContextManager, metaclass=ABCMeta): - """Abstract base class for open text to speech systems""" + """Abstract base class for open text to speech systems. + + Expected usage: + + begin_utterance() + speak_text(...) + add_break(...) + set_mark(...) + speak_tokens(...) + speak_text(...) + results = end_utterance() + + In between begin_utterance() and end_utterance(), the voice/language may + also be changed. + """ @property @abstractmethod def voice(self) -> str: - pass + """Get the current voice key""" @voice.setter def voice(self, new_voice: str): - pass + """Set the current voice key""" @property @abstractmethod def language(self) -> str: - pass + """Get the current voice language""" @language.setter def language(self, new_language: str): - pass + """Set the current voice language""" def shutdown(self): - pass + """Called by the host program when the text to speech system should be stopped""" def __exit__(self, exc_type, exc_value, traceback): + """Automatically call shutdown when context manager has exited""" self.shutdown() @abstractmethod def get_voices(self) -> typing.Iterable[Voice]: - pass + """Returns an iterable of available voices""" @abstractmethod def begin_utterance(self): - pass + """Begins a new utterance""" @abstractmethod def speak_text(self, text: str): - pass + """Speaks text using the underlying system's tokenization mechanism. + + Becomes an AudioResult in end_utterance() + """ @abstractmethod def speak_tokens(self, tokens: typing.Iterable[BaseToken]): - pass + """Speak user-defined tokens. + + Becomes an AudioResult in end_utterance() + """ @abstractmethod def add_break(self, time_ms: int): - pass + """Add milliseconds of silence to the current utterance. + + Becomes an AudioResult in end_utterance() + """ @abstractmethod def set_mark(self, name: str): - pass + """Set a named mark at this point in the utterance. + + Becomes a MarkResult in end_utterance() + """ @abstractmethod def end_utterance(self) -> typing.Iterable[BaseResult]: - pass + """Complete an utterance after begin_utterance(). + + Returns an iterable of results (audio, marks, etc.) + """ diff --git a/opentts-abc/opentts_abc/ssml.py b/opentts-abc/opentts_abc/ssml.py index e96bea4..0921f46 100644 --- a/opentts-abc/opentts_abc/ssml.py +++ b/opentts-abc/opentts_abc/ssml.py @@ -1,4 +1,5 @@ #!/usr/bin/env python3 +"""Support for Speech Synthesis Markup Language (SSML)""" import enum import logging import re @@ -6,14 +7,7 @@ import typing import xml.etree.ElementTree as etree from dataclasses import dataclass -from opentts_abc import ( - BaseResult, - Phonemes, - SayAs, - Settings, - TextToSpeechSystem, - Word, -) +from opentts_abc import BaseResult, Phonemes, SayAs, TextToSpeechSystem, Word LOG = logging.getLogger("opentts_abc.ssml") NO_NAMESPACE_PATTERN = re.compile(r"^{[^}]+}") @@ -43,15 +37,6 @@ class ParsingState(int, enum.Enum): IN_PHONEME = enum.auto() """Inside """ - IN_LEXICON = enum.auto() - """Inside """ - - IN_LEXICON_GRAPHEME = enum.auto() - """Inside ...""" - - IN_LEXICON_PHONEME = enum.auto() - """Inside ...""" - IN_METADATA = enum.auto() """Inside """ @@ -63,18 +48,24 @@ class ParsingState(int, enum.Enum): class SSMLSpeaker: + """Wrapper for TextToSpeechSystem that parses/implements SSML. + + See: https://www.w3.org/TR/speech-synthesis11/ + """ + def __init__(self, tts: TextToSpeechSystem): - self.state_stack: typing.List[ParsingState] = [ParsingState.DEFAULT] - self.element_stack: typing.List[etree.Element] = [] - self.voice_stack: typing.List[str] = [] - self.lang_stack: typing.List[str] = [] - self.interpret_as: typing.Optional[str] = None - self.say_as_format: typing.Optional[str] = None + self._state_stack: typing.List[ParsingState] = [ParsingState.DEFAULT] + self._element_stack: typing.List[etree.Element] = [] + self._voice_stack: typing.List[str] = [] + self._lang_stack: typing.List[str] = [] + self._interpret_as: typing.Optional[str] = None + self._say_as_format: typing.Optional[str] = None self.tts = tts def speak( self, ssml: typing.Union[str, etree.Element] ) -> typing.Iterable[BaseResult]: + """Parses and realizes a set of SSML utterances using the underlying TextToSpeechSystem""" if isinstance(ssml, etree.Element): root_element = ssml @@ -84,39 +75,39 @@ class SSMLSpeaker: # Process sub-elements and text chunks for elem_or_text in text_and_elements(root_element): if isinstance(elem_or_text, str): - if self.state in {ParsingState.IN_METADATA}: + if self._state in {ParsingState.IN_METADATA}: # Skip metadata text continue # Text chunk text = typing.cast(str, elem_or_text) - self.handle_text(text) + self._handle_text(text) elif isinstance(elem_or_text, EndElement): # End of an element (e.g., ) end_elem = typing.cast(EndElement, elem_or_text) end_tag = tag_no_namespace(end_elem.element.tag) if end_tag == "s": - yield from self.handle_end_sentence() + yield from self._handle_end_sentence() elif end_tag in {"w", "token"}: - self.handle_end_word() + self._handle_end_word() elif end_tag in {"phoneme"}: - self.handle_end_phoneme() + self._handle_end_phoneme() elif end_tag == "voice": - self.handle_end_voice() + self._handle_end_voice() elif end_tag == "say-as": - self.handle_end_say_as() + self._handle_end_say_as() elif end_tag in {"sub"}: # Handled in handle_text pass elif end_tag in {"metadata", "meta"}: - self.handle_end_metadata() + self._handle_end_metadata() elif end_tag == "speak": - yield from self.handle_end_speak() + yield from self._handle_end_speak() else: LOG.debug("Ignoring end tag: %s", end_tag) else: - if self.state in {ParsingState.IN_METADATA}: + if self._state in {ParsingState.IN_METADATA}: # Skip metadata text continue @@ -132,86 +123,90 @@ class SSMLSpeaker: elem_tag = tag_no_namespace(elem.tag) if elem_tag == "s": - self.handle_begin_sentence() + self._handle_begin_sentence() elif elem_tag in {"w", "token"}: - self.handle_begin_word(elem) + self._handle_begin_word(elem) elif elem_tag == "sub": - self.handle_begin_sub(elem) + self._handle_begin_sub(elem) elif elem_tag == "phoneme": - self.handle_begin_phoneme(elem) + self._handle_begin_phoneme(elem) elif elem_tag == "break": - self.handle_break(elem) + self._handle_break(elem) elif elem_tag == "mark": - self.handle_mark(elem) + self._handle_mark(elem) elif elem_tag == "voice": - self.handle_begin_voice(elem) + self._handle_begin_voice(elem) elif elem_tag == "say-as": - self.handle_begin_say_as(elem) + self._handle_begin_say_as(elem) elif elem_tag in {"metadata", "meta"}: - self.handle_begin_metadata() + self._handle_begin_metadata() else: LOG.debug("Ignoring start tag: %s", elem_tag) - assert self.state in { + assert self._state in { ParsingState.IN_SENTENCE, ParsingState.DEFAULT, - }, self.state - if self.state in {ParsingState.IN_SENTENCE}: - yield from self.handle_end_sentence() + }, self._state + + if self._state in {ParsingState.IN_SENTENCE}: + yield from self._handle_end_sentence() # ------------------------------------------------------------------------- - def handle_text(self, text: str): - assert self.state in { + def _handle_text(self, text: str): + """Handle sentence/word text""" + assert self._state in { ParsingState.DEFAULT, ParsingState.IN_SENTENCE, ParsingState.IN_WORD, ParsingState.IN_SUB, ParsingState.IN_PHONEME, ParsingState.IN_SAY_AS, - }, self.state + }, self._state - if self.state == ParsingState.IN_PHONEME: + if self._state == ParsingState.IN_PHONEME: # Phonemes were emitted in handle_begin_phoneme return - if self.state == ParsingState.IN_SUB: + if self._state == ParsingState.IN_SUB: # Substitute text - assert self.element is not None - text = attrib_no_namespace(self.element, "alias", "") + assert self._element is not None + text = attrib_no_namespace(self._element, "alias", "") LOG.debug("alias text: %s", text) # Terminate early - self.handle_end_sub() + self._handle_end_sub() - if self.state == ParsingState.DEFAULT: - self.handle_begin_sentence() + if self._state == ParsingState.DEFAULT: + self._handle_begin_sentence() LOG.debug("text: %s", text) - if self.state == ParsingState.IN_WORD: - self.handle_word(text, self.element) - elif self.state == ParsingState.IN_SAY_AS: - assert self.interpret_as is not None + if self._state == ParsingState.IN_WORD: + self._handle_word(text, self._element) + elif self._state == ParsingState.IN_SAY_AS: + assert self._interpret_as is not None self.tts.speak_tokens( [ SayAs( text=text, - interpret_as=self.interpret_as, - format=self.say_as_format, + interpret_as=self._interpret_as, + format=self._say_as_format, ) ] ) else: self.tts.speak_text(text) - def handle_begin_word(self, elem: etree.Element): + def _handle_begin_word(self, elem: etree.Element): + """Handle or """ LOG.debug("begin word") - self.push_element(elem) - self.push_state(ParsingState.IN_WORD) + self._push_element(elem) + self._push_state(ParsingState.IN_WORD) - def handle_word(self, text: str, elem: typing.Optional[etree.Element] = None): - assert self.state in {ParsingState.IN_WORD}, self.state + def _handle_word(self, text: str, elem: typing.Optional[etree.Element] = None): + """Handle text from word""" + assert self._state in {ParsingState.IN_WORD}, self._state role: typing.Optional[str] = None if elem is not None: @@ -219,28 +214,32 @@ class SSMLSpeaker: self.tts.speak_tokens([Word(text, role=role)]) - def handle_end_word(self): + def _handle_end_word(self): + """Handle or """ LOG.debug("end word") - assert self.state in {ParsingState.IN_WORD}, self.state - self.pop_state() - self.pop_element() + assert self._state in {ParsingState.IN_WORD}, self._state + self._pop_state() + self._pop_element() - def handle_begin_sub(self, elem: etree.Element): + def _handle_begin_sub(self, elem: etree.Element): + """Handle """ LOG.debug("begin sub") - self.push_element(elem) - self.push_state(ParsingState.IN_SUB) + self._push_element(elem) + self._push_state(ParsingState.IN_SUB) - def handle_end_sub(self): + def _handle_end_sub(self): + """Handle """ LOG.debug("end sub") - assert self.state in {ParsingState.IN_SUB}, self.state - self.pop_state() - self.pop_element() + assert self._state in {ParsingState.IN_SUB}, self._state + self._pop_state() + self._pop_element() - def handle_begin_phoneme(self, elem: etree.Element): + def _handle_begin_phoneme(self, elem: etree.Element): + """Handle """ LOG.debug("begin phoneme") - if self.state == ParsingState.DEFAULT: - self.handle_begin_sentence() + if self._state == ParsingState.DEFAULT: + self._handle_begin_sentence() phonemes = attrib_no_namespace(elem, "ph", "") alphabet = attrib_no_namespace(elem, "alphabet", "") @@ -249,61 +248,70 @@ class SSMLSpeaker: self.tts.speak_tokens([Phonemes(text=phonemes, alphabet=alphabet)]) - self.push_element(elem) - self.push_state(ParsingState.IN_PHONEME) + self._push_element(elem) + self._push_state(ParsingState.IN_PHONEME) - def handle_end_phoneme(self): + def _handle_end_phoneme(self): + """Handle """ LOG.debug("end phoneme") - assert self.state in {ParsingState.IN_PHONEME}, self.state - self.pop_state() - self.pop_element() + assert self._state in {ParsingState.IN_PHONEME}, self._state + self._pop_state() + self._pop_element() - def handle_begin_metadata(self): + def _handle_begin_metadata(self): + """Handle """ LOG.debug("begin metadata") - self.push_state(ParsingState.IN_METADATA) + self._push_state(ParsingState.IN_METADATA) - def handle_end_metadata(self): + def _handle_end_metadata(self): + """Handle """ LOG.debug("end metadata") - assert self.state in {ParsingState.IN_METADATA}, self.state - self.pop_state() + assert self._state in {ParsingState.IN_METADATA}, self._state + self._pop_state() - def handle_begin_sentence(self): + def _handle_begin_sentence(self): + """Handle """ LOG.debug("begin sentence") - assert self.state in {ParsingState.DEFAULT}, self.state - self.push_state(ParsingState.IN_SENTENCE) + assert self._state in {ParsingState.DEFAULT}, self._state + self._push_state(ParsingState.IN_SENTENCE) self.tts.begin_utterance() - def handle_end_sentence(self) -> typing.Iterable[BaseResult]: + def _handle_end_sentence(self) -> typing.Iterable[BaseResult]: + """Handle """ LOG.debug("end sentence") - assert self.state in {ParsingState.IN_SENTENCE}, self.state - self.pop_state() + assert self._state in {ParsingState.IN_SENTENCE}, self._state + self._pop_state() yield from self.tts.end_utterance() - def handle_end_speak(self) -> typing.Iterable[BaseResult]: + def _handle_end_speak(self) -> typing.Iterable[BaseResult]: + """Handle """ LOG.debug("end speak") - assert self.state in {ParsingState.DEFAULT}, self.state + assert self._state in {ParsingState.DEFAULT}, self._state yield from self.tts.end_utterance() - def handle_begin_voice(self, elem: etree.Element): + def _handle_begin_voice(self, elem: etree.Element): + """Handle """ LOG.debug("begin voice") voice_name = attrib_no_namespace(elem, "name") LOG.debug("voice: %s", voice_name) - self.push_voice(voice_name) + self._push_voice(voice_name) # Set new voice self.tts.voice = voice_name - def handle_end_voice(self): + def _handle_end_voice(self): + """Handle """ LOG.debug("end voice") - voice_name = self.pop_voice() + voice_name = self._pop_voice() # Restore voice self.tts.voice = voice_name - def handle_break(self, elem: etree.Element): + def _handle_break(self, elem: etree.Element): + """Handle """ time_str = attrib_no_namespace(elem, "time", "").strip() time_ms: int = 0 @@ -316,90 +324,105 @@ class SSMLSpeaker: LOG.debug("Break: %s ms", time_ms) self.tts.add_break(time_ms) - def handle_mark(self, elem: etree.Element): + def _handle_mark(self, elem: etree.Element): + """Handle """ name = attrib_no_namespace(elem, "name", "") LOG.debug("Mark: %s", name) self.tts.set_mark(name) - def handle_begin_say_as(self, elem: etree.Element): + def _handle_begin_say_as(self, elem: etree.Element): + """Handle """ LOG.debug("begin say-as") - self.interpret_as = attrib_no_namespace(elem, "interpret-as", "") - self.say_as_format = attrib_no_namespace(elem, "format", "") + self._interpret_as = attrib_no_namespace(elem, "interpret-as", "") + self._say_as_format = attrib_no_namespace(elem, "format", "") - LOG.debug("Say as %s, format=%s", self.interpret_as, self.say_as_format) - self.push_state(ParsingState.IN_SAY_AS) + LOG.debug("Say as %s, format=%s", self._interpret_as, self._say_as_format) + self._push_state(ParsingState.IN_SAY_AS) - def handle_end_say_as(self): + def _handle_end_say_as(self): + """Handle """ LOG.debug("end say-as") - assert self.state in {ParsingState.IN_SAY_AS} - self.interpret_as = None - self.say_as_format = None - self.pop_state() + assert self._state in {ParsingState.IN_SAY_AS} + self._interpret_as = None + self._say_as_format = None + self._pop_state() # ------------------------------------------------------------------------- @property - def state(self) -> ParsingState: - if self.state_stack: - return self.state_stack[-1] + def _state(self) -> ParsingState: + """Get state at the top of the stack""" + if self._state_stack: + return self._state_stack[-1] return ParsingState.DEFAULT - def push_state(self, new_state: ParsingState): - self.state_stack.append(new_state) + def _push_state(self, new_state: ParsingState): + """Push new state on to the stack""" + self._state_stack.append(new_state) - def pop_state(self) -> ParsingState: - if self.state_stack: - return self.state_stack.pop() + def _pop_state(self) -> ParsingState: + """Pop state off the stack""" + if self._state_stack: + return self._state_stack.pop() return ParsingState.DEFAULT @property - def element(self) -> typing.Optional[etree.Element]: - if self.element_stack: - return self.element_stack[-1] + def _element(self) -> typing.Optional[etree.Element]: + """Get XML element at the top of the stack""" + if self._element_stack: + return self._element_stack[-1] return None - def push_element(self, new_element: etree.Element): - self.element_stack.append(new_element) + def _push_element(self, new_element: etree.Element): + """Push new XML element on to the stack""" + self._element_stack.append(new_element) - def pop_element(self) -> typing.Optional[etree.Element]: - if self.element_stack: - return self.element_stack.pop() + def _pop_element(self) -> typing.Optional[etree.Element]: + """Pop XML element off the stack""" + if self._element_stack: + return self._element_stack.pop() return None @property - def lang(self) -> typing.Optional[str]: - if self.lang_stack: - return self.lang_stack[-1] + def _lang(self) -> typing.Optional[str]: + """Get language at the top of the stack""" + if self._lang_stack: + return self._lang_stack[-1] return self.tts.language - def push_lang(self, new_lang: str): - self.lang_stack.append(new_lang) + def _push_lang(self, new_lang: str): + """Push new language on to the stack""" + self._lang_stack.append(new_lang) - def pop_lang(self) -> typing.Optional[str]: - if self.lang_stack: - return self.lang_stack.pop() + def _pop_lang(self) -> typing.Optional[str]: + """Pop language off the stop of the stack""" + if self._lang_stack: + return self._lang_stack.pop() return self.tts.language @property - def voice(self) -> typing.Optional[str]: - if self.voice_stack: - return self.voice_stack[-1] + def _voice(self) -> typing.Optional[str]: + """Get voice at the top of the stack""" + if self._voice_stack: + return self._voice_stack[-1] return self.tts.voice - def push_voice(self, new_voice: str): - self.voice_stack.append(new_voice) + def _push_voice(self, new_voice: str): + """Push new voice on to the stack""" + self._voice_stack.append(new_voice) - def pop_voice(self) -> typing.Optional[str]: - if self.voice_stack: - return self.voice_stack.pop() + def _pop_voice(self) -> typing.Optional[str]: + """Pop voice off the top of the stack""" + if self._voice_stack: + return self._voice_stack.pop() return self.tts.voice diff --git a/opentts-abc/pylintrc b/opentts-abc/pylintrc new file mode 100644 index 0000000..9a76583 --- /dev/null +++ b/opentts-abc/pylintrc @@ -0,0 +1,39 @@ +[MESSAGES CONTROL] +disable= + format, + abstract-class-little-used, + abstract-method, + cyclic-import, + duplicate-code, + global-statement, + import-outside-toplevel, + inconsistent-return-statements, + locally-disabled, + not-context-manager, + redefined-variable-type, + too-few-public-methods, + too-many-arguments, + too-many-branches, + too-many-instance-attributes, + too-many-lines, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + too-many-boolean-expressions, + unnecessary-pass, + unused-argument, + broad-except, + too-many-nested-blocks, + invalid-name, + unused-import, + no-self-use, + fixme, + useless-super-delegation, + missing-module-docstring, + missing-class-docstring, + missing-function-docstring, + import-error + +[FORMAT] +expected-line-ending-format=LF