From 245ac200d1e30d80f96bd8bc0a7201f241cc544c Mon Sep 17 00:00:00 2001 From: Michael Hansen Date: Fri, 25 Mar 2022 13:20:28 -0400 Subject: [PATCH] Cleanup in mimic3_tts --- mimic3-tts/mimic3_tts/__init__.py | 5 + mimic3-tts/mimic3_tts/__main__.py | 25 ++-- mimic3-tts/mimic3_tts/_resources.py | 19 +++ mimic3-tts/mimic3_tts/config.py | 12 -- mimic3-tts/mimic3_tts/tts.py | 219 +++++++++++++++++----------- mimic3-tts/mimic3_tts/voice.py | 3 +- mimic3-tts/mypy.ini | 3 + mimic3-tts/pylintrc | 3 +- mimic3-tts/requirements.txt | 1 + mimic3-tts/setup.cfg | 4 + opentts-abc/README.md | 23 +++ opentts-abc/opentts_abc/ssml.py | 38 ++++- 12 files changed, 236 insertions(+), 119 deletions(-) create mode 100644 mimic3-tts/mimic3_tts/_resources.py diff --git a/mimic3-tts/mimic3_tts/__init__.py b/mimic3-tts/mimic3_tts/__init__.py index a0b8eac..460732e 100644 --- a/mimic3-tts/mimic3_tts/__init__.py +++ b/mimic3-tts/mimic3_tts/__init__.py @@ -1,4 +1,9 @@ +from pathlib import Path + from opentts_abc import AudioResult, MarkResult from opentts_abc.ssml import SSMLSpeaker +from ._resources import __version__ from .tts import Mimic3Settings, Mimic3TextToSpeechSystem + +__author__ = "Michael Hansen" diff --git a/mimic3-tts/mimic3_tts/__main__.py b/mimic3-tts/mimic3_tts/__main__.py index f145da0..edf45ec 100644 --- a/mimic3-tts/mimic3_tts/__main__.py +++ b/mimic3-tts/mimic3_tts/__main__.py @@ -2,16 +2,11 @@ import logging import wave -logging.basicConfig(level=logging.DEBUG) - from opentts_abc.ssml import SSMLSpeaker -from mimic3_tts.tts import ( - AudioResult, - MarkResult, - Mimic3Settings, - Mimic3TextToSpeechSystem, -) +from .tts import AudioResult, MarkResult, Mimic3Settings, Mimic3TextToSpeechSystem + +logging.basicConfig(level=logging.DEBUG) settings = Mimic3Settings() tts = Mimic3TextToSpeechSystem(settings) @@ -22,21 +17,21 @@ speaker = SSMLSpeaker(tts) # ssml = 'HelloWorld' # ssml = 'Hello world' # ssml = '12' -ssml = ''' +ssml = """ - - Today is 1/2. - - + Today is a test. + This is another test. - Soy el 1. + + Soy el 1. + -''' +""" wav_file: wave.Wave_write = wave.open("out.wav", "wb") diff --git a/mimic3-tts/mimic3_tts/_resources.py b/mimic3-tts/mimic3_tts/_resources.py new file mode 100644 index 0000000..ff0b076 --- /dev/null +++ b/mimic3-tts/mimic3_tts/_resources.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python3 +import os +import typing +from pathlib import Path + +try: + import importlib.resources + + files = importlib.resources.files +except (ImportError, AttributeError): + # Backport for Python < 3.9 + import importlib_resources # type: ignore + + files = importlib_resources.files + +_PACKAGE = "mimic3_tts" +_DIR = Path(typing.cast(os.PathLike, files(_PACKAGE))) + +__version__ = (_DIR / "VERSION").read_text(encoding="utf-8").strip() diff --git a/mimic3-tts/mimic3_tts/config.py b/mimic3-tts/mimic3_tts/config.py index 87edf5a..7def58a 100644 --- a/mimic3-tts/mimic3_tts/config.py +++ b/mimic3-tts/mimic3_tts/config.py @@ -272,18 +272,6 @@ class TrainingConfig(DataClassJsonMixin): """Save config as JSON to a file""" json.dump(self.to_dict(), config_file, indent=4) - def get_speaker_id(self, dataset_name: str, speaker_name: str) -> int: - if self.speaker_id_map is None: - self.speaker_id_map = {} - - full_speaker_name = f"{dataset_name}_{speaker_name}" - speaker_id = self.speaker_id_map.get(full_speaker_name) - if speaker_id is None: - speaker_id = len(self.speaker_id_map) - self.speaker_id_map[full_speaker_name] = speaker_id - - return speaker_id - @staticmethod def load(config_file: typing.TextIO) -> "TrainingConfig": """Load config from a JSON file""" diff --git a/mimic3-tts/mimic3_tts/tts.py b/mimic3-tts/mimic3_tts/tts.py index 585af9b..b3bc827 100644 --- a/mimic3-tts/mimic3_tts/tts.py +++ b/mimic3-tts/mimic3_tts/tts.py @@ -1,18 +1,13 @@ #!/usr/bin/env python3 +"""Implementation of OpenTTS for Mimic 3""" +import itertools import logging -import time import typing from copy import deepcopy from dataclasses import dataclass, field from pathlib import Path -from xml.sax.saxutils import escape as xmlescape -import gruut -import numpy as np -import onnxruntime -import phonemes2ids -from gruut.const import LookupPhonemes, WordRole -from gruut_ipa import IPA, Phoneme, guess_phonemes +from gruut_ipa import IPA from opentts_abc import ( AudioResult, BaseResult, @@ -24,15 +19,16 @@ from opentts_abc import ( Voice, Word, ) +from xdgenvpy import XDG -from mimic3_tts.config import TrainingConfig -from mimic3_tts.voice import Mimic3Voice, SPEAKER_TYPE +from .config import TrainingConfig +from .voice import SPEAKER_TYPE, Mimic3Voice _DIR = Path(__file__).parent _LOGGER = logging.getLogger(__name__) -PHONEMES_LIST = typing.List[typing.List[str]] +PHONEMES_LIST_TYPE = typing.List[typing.List[str]] DEFAULT_VOICE = "en_US/vctk_low" DEFAULT_LANGUAGE = "en_US" @@ -43,25 +39,53 @@ DEFAULT_LANGUAGE = "en_US" @dataclass class Mimic3Settings: + """Settings for Mimic 3 text to speech system""" + voice: typing.Optional[str] = None + """Default voice key""" + language: typing.Optional[str] = None + """Default language (e.g., "en_US")""" + voices_directories: typing.Optional[typing.Iterable[typing.Union[str, Path]]] = None + """Directories to search for voices (/)""" + speaker: typing.Optional[SPEAKER_TYPE] = None + """Default speaker name or id""" + length_scale: typing.Optional[float] = None + """Default length scale (use voice config if None)""" + noise_scale: typing.Optional[float] = None + """Default noise scale (use voice config if None)""" + noise_w: typing.Optional[float] = None + """Default noise W (use voice config if None)""" + text_language: typing.Optional[str] = None + """Language of text (use voice language if None)""" + sample_rate: int = 22050 + """Sample rate of silence from add_break() in Hertz""" @dataclass class Mimic3Phonemes: + """Pending task to synthesize audio from phonemes with specific settings""" + current_settings: Mimic3Settings + """Settings used to synthesize audio""" + phonemes: typing.List[typing.List[str]] = field(default_factory=list) + """Phonemes for synthesis""" + is_utterance: bool = True + """True if this is the end of a full utterance""" class VoiceNotFoundError(Exception): + """Raised if a voice cannot be found""" + def __init__(self, voice: str): super().__init__(f"Voice not found: {voice}") @@ -78,57 +102,37 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): self._results: typing.List[typing.Union[BaseResult, Mimic3Phonemes]] = [] self._loaded_voices: typing.Dict[str, Mimic3Voice] = {} - @property - def voice(self) -> str: - return self.settings.voice or DEFAULT_VOICE - - @voice.setter - def voice(self, new_voice: str): - if new_voice != self.settings.voice: - # Clear speaker on voice change - self.speaker = None - - self.settings.voice = new_voice - - if "#" in self.settings.voice: - # Split - voice, speaker = self.settings.voice.split("#", maxsplit=1) - self.settings.voice = voice - self.speaker = speaker - - @property - def speaker(self) -> typing.Optional[SPEAKER_TYPE]: - return self.settings.speaker - - @speaker.setter - def speaker(self, new_speaker: typing.Optional[SPEAKER_TYPE]): - self.settings.speaker = new_speaker - - @property - def language(self) -> str: - return self.settings.language or DEFAULT_LANGUAGE - - @language.setter - def language(self, new_language: str): - self.settings.language = new_language - @staticmethod def get_default_voices_directories() -> typing.List[Path]: - return [_DIR.parent.parent / "voices"] + """Get list of directories to search for voices by default. + + On Linux, this is typically: + - $HOME/.local/share/mimic3 + - /usr/local/share/mimic3 + - /usr/share/mimic3 + """ + data_dirs = [Path(d) / "mimic3" for d in XDG().XDG_DATA_DIRS.split(":")] + return [_DIR.parent.parent / "voices"] + data_dirs def get_voices(self) -> typing.Iterable[Voice]: - voices_dirs = ( - self.settings.voices_directories - or Mimic3TextToSpeechSystem.get_default_voices_directories() - ) + """Returns an iterable of all available voices""" + voices_dirs: typing.Iterable[ + typing.Union[str, Path] + ] = Mimic3TextToSpeechSystem.get_default_voices_directories() + + if self.settings.voices_directories is not None: + voices_dirs = itertools.chain(self.settings.voices_directories, voices_dirs) # voices/// for voices_dir in voices_dirs: voices_dir = Path(voices_dir) if not voices_dir.is_dir(): + _LOGGER.debug("Skipping voice directory %s", voices_dir) continue + _LOGGER.debug("Searching %s for voices", voices_dir) + for lang_dir in voices_dir.iterdir(): if not lang_dir.is_dir(): continue @@ -137,6 +141,7 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): if not voice_dir.is_dir(): continue + _LOGGER.debug("Voice found in %s", voice_dir) voice_lang = lang_dir.name # Load config @@ -176,9 +181,50 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): properties=properties, ) + def preload_voice(self, voice_key: str): + """Ensure voice is loaded in memory before synthesis""" + self._get_or_load_voice(voice_key) + + # ------------------------------------------------------------------------- + + @property + def voice(self) -> str: + return self.settings.voice or DEFAULT_VOICE + + @voice.setter + def voice(self, new_voice: str): + if new_voice != self.settings.voice: + # Clear speaker on voice change + self.speaker = None + + self.settings.voice = new_voice + + if "#" in self.settings.voice: + # Split + voice, speaker = self.settings.voice.split("#", maxsplit=1) + self.settings.voice = voice + self.speaker = speaker + + @property + def speaker(self) -> typing.Optional[SPEAKER_TYPE]: + return self.settings.speaker + + @speaker.setter + def speaker(self, new_speaker: typing.Optional[SPEAKER_TYPE]): + self.settings.speaker = new_speaker + + @property + def language(self) -> str: + return self.settings.language or DEFAULT_LANGUAGE + + @language.setter + def language(self, new_language: str): + self.settings.language = new_language + def begin_utterance(self): pass + # pylint: disable=arguments-differ def speak_text(self, text: str, text_language: typing.Optional[str] = None): voice = self._get_or_load_voice(self.voice) @@ -187,44 +233,18 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): Mimic3Phonemes( current_settings=deepcopy(self.settings), phonemes=sent_phonemes, + is_utterance=False, ) ) - def _speak_sentence_phonemes( - self, - sent_phonemes, - settings: typing.Optional[Mimic3Settings] = None, - ) -> AudioResult: - settings = settings or self.settings - voice = self._get_or_load_voice(settings.voice or self.voice) - sent_phoneme_ids = voice.phonemes_to_ids(sent_phonemes) - - _LOGGER.debug("phonemes=%s, ids=%s", sent_phonemes, sent_phoneme_ids) - - audio = voice.ids_to_audio( - sent_phoneme_ids, - speaker=self.speaker, - length_scale=settings.length_scale, - noise_scale=settings.noise_scale, - noise_w=settings.noise_w, - ) - - audio_bytes = audio.tobytes() - return AudioResult( - sample_rate_hz=voice.config.audio.sample_rate, - audio_bytes=audio_bytes, - # 16-bit mono - sample_width_bytes=2, - num_channels=1, - ) - + # pylint: disable=arguments-differ def speak_tokens( self, tokens: typing.Iterable[BaseToken], text_language: typing.Optional[str] = None, ): voice = self._get_or_load_voice(self.voice) - token_phonemes: PHONEMES_LIST = [] + token_phonemes: PHONEMES_LIST_TYPE = [] for token in tokens: if isinstance(token, Word): @@ -250,7 +270,9 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): if token_phonemes: self._results.append( Mimic3Phonemes( - current_settings=deepcopy(self.settings), phonemes=token_phonemes + current_settings=deepcopy(self.settings), + phonemes=token_phonemes, + is_utterance=False, ) ) @@ -275,7 +297,7 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): def end_utterance(self) -> typing.Iterable[BaseResult]: last_settings = self.settings - sent_phonemes: PHONEMES_LIST = [] + sent_phonemes: PHONEMES_LIST_TYPE = [] for result in self._results: if isinstance(result, Mimic3Phonemes): @@ -298,15 +320,44 @@ class Mimic3TextToSpeechSystem(TextToSpeechSystem): yield result if sent_phonemes: - yield self._speak_sentence_phonemes(sent_phonemes) + yield self._speak_sentence_phonemes(sent_phonemes, settings=last_settings) sent_phonemes.clear() self._results.clear() - def preload_voice(self, voice_key: str): - self._get_or_load_voice(voice_key) + # ------------------------------------------------------------------------- + + def _speak_sentence_phonemes( + self, + sent_phonemes, + settings: typing.Optional[Mimic3Settings] = None, + ) -> AudioResult: + """Synthesize audio from phonemes using given setings""" + settings = settings or self.settings + voice = self._get_or_load_voice(settings.voice or self.voice) + sent_phoneme_ids = voice.phonemes_to_ids(sent_phonemes) + + _LOGGER.debug("phonemes=%s, ids=%s", sent_phonemes, sent_phoneme_ids) + + audio = voice.ids_to_audio( + sent_phoneme_ids, + speaker=self.speaker, + length_scale=settings.length_scale, + noise_scale=settings.noise_scale, + noise_w=settings.noise_w, + ) + + audio_bytes = audio.tobytes() + return AudioResult( + sample_rate_hz=voice.config.audio.sample_rate, + audio_bytes=audio_bytes, + # 16-bit mono + sample_width_bytes=2, + num_channels=1, + ) def _get_or_load_voice(self, voice_key: str) -> Mimic3Voice: + """Get a loaded voice or load from the file system""" existing_voice = self._loaded_voices.get(voice_key) if existing_voice is not None: return existing_voice diff --git a/mimic3-tts/mimic3_tts/voice.py b/mimic3-tts/mimic3_tts/voice.py index e22adfb..82704f4 100644 --- a/mimic3-tts/mimic3_tts/voice.py +++ b/mimic3-tts/mimic3_tts/voice.py @@ -1,6 +1,5 @@ #!/usr/bin/env python3 import csv -import itertools import logging import time import typing @@ -18,6 +17,8 @@ from gruut_ipa import IPA from mimic3_tts.config import Phonemizer, TrainingConfig from mimic3_tts.utils import audio_float_to_int16 +# ----------------------------------------------------------------------------- + PHONEME_TYPE = str PHONEME_ID_TYPE = int WORD_PHONEMES_TYPE = typing.List[typing.List[PHONEME_TYPE]] diff --git a/mimic3-tts/mypy.ini b/mimic3-tts/mypy.ini index 916fdd7..99a4d79 100644 --- a/mimic3-tts/mypy.ini +++ b/mimic3-tts/mypy.ini @@ -5,3 +5,6 @@ ignore_missing_imports = True [mypy-onnxruntime.*] ignore_missing_imports = True + +[mypy-xdgenvpy.*] +ignore_missing_imports = True diff --git a/mimic3-tts/pylintrc b/mimic3-tts/pylintrc index 9a76583..792fbe6 100644 --- a/mimic3-tts/pylintrc +++ b/mimic3-tts/pylintrc @@ -33,7 +33,8 @@ disable= missing-module-docstring, missing-class-docstring, missing-function-docstring, - import-error + import-error, + relative-import-beyond-top-level [FORMAT] expected-line-ending-format=LF diff --git a/mimic3-tts/requirements.txt b/mimic3-tts/requirements.txt index ad09284..4cc949b 100644 --- a/mimic3-tts/requirements.txt +++ b/mimic3-tts/requirements.txt @@ -5,3 +5,4 @@ numpy<2.0 onnxruntime>=1.6,<2.0 phonemes2ids<2.0 opentts_abc<1.0 +xdgenvpy>2.0,<3 diff --git a/mimic3-tts/setup.cfg b/mimic3-tts/setup.cfg index 0076bbf..d09a842 100644 --- a/mimic3-tts/setup.cfg +++ b/mimic3-tts/setup.cfg @@ -13,6 +13,10 @@ ignore = D202, W504 +# F401 import unused +per-file-ignores = + mimic3_tts/__init__.py:F401 + [isort] multi_line_output = 3 include_trailing_comma=True diff --git a/opentts-abc/README.md b/opentts-abc/README.md index 76476b9..d15fcd9 100644 --- a/opentts-abc/README.md +++ b/opentts-abc/README.md @@ -2,3 +2,26 @@ Base classes for open text to speech systems. + +## SSML + +A subset of [SSML](https://www.w3.org/TR/speech-synthesis11/) is supported in `SSMLSpeaker`: + +* `` - wrap around SSML text + * `lang` - set language for document +* `` - sentence (disables automatic sentence breaking) + * `lang` - set language for sentence +* `` / `` - word (disables automatic tokenization) +* `` - set voice of inner text + * `voice` - name of voice +* `` - force interpretation of inner text + * `interpret-as` - way to interpret text (implementation dependent) + * `format` - way to format text (implementation dependent) +* `` - Pause for given amount of time + * time - seconds ("123s") or milliseconds ("123ms") +* `` - User-defined mark (written to `--mark-file` or part of `TextToSpeechResult`) + * name - name of mark +* `` - substitute `alias` for inner text +* `` - supply phonemes for inner text + * `ph` - phonemes for each word of inner text + * `alphabet` - name of phoneme alphabet (usually "ipa") diff --git a/opentts-abc/opentts_abc/ssml.py b/opentts-abc/opentts_abc/ssml.py index 0921f46..5cf177c 100644 --- a/opentts-abc/opentts_abc/ssml.py +++ b/opentts-abc/opentts_abc/ssml.py @@ -62,6 +62,9 @@ class SSMLSpeaker: self._say_as_format: typing.Optional[str] = None self.tts = tts + self._default_voice = self.tts.voice + self._default_lang = self.tts.language + def speak( self, ssml: typing.Union[str, etree.Element] ) -> typing.Iterable[BaseResult]: @@ -97,6 +100,8 @@ class SSMLSpeaker: self._handle_end_voice() elif end_tag == "say-as": self._handle_end_say_as() + elif end_tag == "lang": + self._handle_end_lang() elif end_tag in {"sub"}: # Handled in handle_text pass @@ -138,6 +143,8 @@ class SSMLSpeaker: self._handle_begin_voice(elem) elif elem_tag == "say-as": self._handle_begin_say_as(elem) + elif elem_tag == "lang": + self._handle_begin_lang(elem) elif elem_tag in {"metadata", "meta"}: self._handle_begin_metadata() else: @@ -287,6 +294,9 @@ class SSMLSpeaker: def _handle_end_speak(self) -> typing.Iterable[BaseResult]: """Handle """ LOG.debug("end speak") + if self._state == ParsingState.IN_SENTENCE: + yield from self._handle_end_sentence() + assert self._state in {ParsingState.DEFAULT}, self._state yield from self.tts.end_utterance() @@ -305,10 +315,11 @@ class SSMLSpeaker: def _handle_end_voice(self): """Handle """ LOG.debug("end voice") - voice_name = self._pop_voice() + self._pop_voice() # Restore voice - self.tts.voice = voice_name + self.tts.voice = self._voice + LOG.debug("voice: %s", self._voice) def _handle_break(self, elem: etree.Element): """Handle """ @@ -348,6 +359,21 @@ class SSMLSpeaker: self._say_as_format = None self._pop_state() + def _handle_begin_lang(self, elem: etree.Element): + """Handle """ + LOG.debug("begin lang") + lang = attrib_no_namespace(elem, "lang") + + LOG.debug("language: %s", lang) + self._push_lang(lang) + + def _handle_end_lang(self): + """Handle """ + LOG.debug("end lang") + self._pop_lang() + + LOG.debug("language: %s", self._lang) + # ------------------------------------------------------------------------- @property @@ -394,7 +420,7 @@ class SSMLSpeaker: if self._lang_stack: return self._lang_stack[-1] - return self.tts.language + return self._default_lang def _push_lang(self, new_lang: str): """Push new language on to the stack""" @@ -405,7 +431,7 @@ class SSMLSpeaker: if self._lang_stack: return self._lang_stack.pop() - return self.tts.language + return self._default_lang @property def _voice(self) -> typing.Optional[str]: @@ -413,7 +439,7 @@ class SSMLSpeaker: if self._voice_stack: return self._voice_stack[-1] - return self.tts.voice + return self._default_voice def _push_voice(self, new_voice: str): """Push new voice on to the stack""" @@ -424,7 +450,7 @@ class SSMLSpeaker: if self._voice_stack: return self._voice_stack.pop() - return self.tts.voice + return self._default_voice # -----------------------------------------------------------------------------