2025-12-01
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonologizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonologizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonologizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Phonemizer module for espeak backend implementation"""
|
||||
@@ -0,0 +1,275 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Low-level bindings to the espeak API"""
|
||||
|
||||
import atexit
|
||||
import ctypes
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import weakref
|
||||
from ctypes import CDLL
|
||||
from pathlib import Path
|
||||
from typing import Union
|
||||
|
||||
from phonemizer.backend.espeak.voice import EspeakVoice
|
||||
|
||||
if sys.platform != 'win32':
|
||||
# cause a crash on Windows
|
||||
import dlinfo
|
||||
|
||||
|
||||
class EspeakAPI:
|
||||
"""Exposes the espeak API to the EspeakWrapper
|
||||
|
||||
This class exposes only low-level bindings to the API and should not be
|
||||
used directly.
|
||||
|
||||
"""
|
||||
|
||||
def __init__(self, library: Union[str, Path]):
|
||||
# set to None to avoid an AttributeError in _delete if the __init__
|
||||
# method raises, will be properly initialized below
|
||||
self._library = None
|
||||
|
||||
# Because the library is not designed to be wrapped nor to be used in
|
||||
# multithreaded/multiprocess contexts (massive use of global variables)
|
||||
# we need a copy of the original library for each instance of the
|
||||
# wrapper... (see "man dlopen" on Linux/MacOS: we cannot load two times
|
||||
# the same library because a reference is then returned by dlopen). The
|
||||
# tweak is therefore to make a copy of the original library in a
|
||||
# different (temporary) directory.
|
||||
try:
|
||||
# load the original library in order to retrieve its full path?
|
||||
# Forced as str as it is required on Windows.
|
||||
espeak: CDLL = ctypes.cdll.LoadLibrary(str(library))
|
||||
library_path = self._shared_library_path(espeak)
|
||||
del espeak
|
||||
except OSError as error:
|
||||
raise RuntimeError(
|
||||
f'failed to load espeak library: {str(error)}') from None
|
||||
|
||||
# will be automatically destroyed after use
|
||||
self._tempdir = tempfile.mkdtemp()
|
||||
|
||||
# properly exit when the wrapper object is destroyed (see
|
||||
# https://docs.python.org/3/library/weakref.html#comparing-finalizers-with-del-methods).
|
||||
# But... weakref implementation does not work on windows so we register
|
||||
# the cleanup with atexit. This means that, on Windows, all the
|
||||
# temporary directories created by EspeakAPI instances will remain on
|
||||
# disk until the Python process exit.
|
||||
if sys.platform == 'win32': # pragma: nocover
|
||||
atexit.register(self._delete_win32)
|
||||
else:
|
||||
weakref.finalize(self, self._delete, self._library, self._tempdir)
|
||||
|
||||
espeak_copy = pathlib.Path(self._tempdir) / library_path.name
|
||||
shutil.copy(library_path, espeak_copy, follow_symlinks=False)
|
||||
|
||||
# finally load the library copy and initialize it. 0x02 is
|
||||
# AUDIO_OUTPUT_SYNCHRONOUS in the espeak API
|
||||
self._library = ctypes.cdll.LoadLibrary(str(espeak_copy))
|
||||
try:
|
||||
if self._library.espeak_Initialize(0x02, 0, None, 0) <= 0:
|
||||
raise RuntimeError( # pragma: nocover
|
||||
'failed to initialize espeak shared library')
|
||||
except AttributeError: # pragma: nocover
|
||||
raise RuntimeError(
|
||||
'failed to load espeak library') from None
|
||||
|
||||
# the path to the original one (the copy is considered an
|
||||
# implementation detail and is not exposed)
|
||||
self._library_path = library_path
|
||||
|
||||
def _delete_win32(self): # pragma: nocover
|
||||
# Windows does not support static methods with ctypes libraries
|
||||
# (library == None) so we use a proxy method...
|
||||
self._delete(self._library, self._tempdir)
|
||||
|
||||
@staticmethod
|
||||
def _delete(library, tempdir):
|
||||
try:
|
||||
# clean up the espeak library allocated memory
|
||||
library.espeak_Terminate()
|
||||
except AttributeError: # library not loaded
|
||||
pass
|
||||
|
||||
# on Windows it is required to unload the library or the .dll file
|
||||
# cannot be erased from the temporary directory
|
||||
if sys.platform == 'win32': # pragma: nocover
|
||||
# pylint: disable=import-outside-toplevel
|
||||
# pylint: disable=protected-access
|
||||
# pylint: disable=no-member
|
||||
import _ctypes
|
||||
_ctypes.FreeLibrary(library._handle)
|
||||
|
||||
# clean up the tempdir containing the copy of the library
|
||||
shutil.rmtree(tempdir)
|
||||
|
||||
@property
|
||||
def library_path(self):
|
||||
"""Absolute path to the espeak library being in use"""
|
||||
return self._library_path
|
||||
|
||||
@staticmethod
|
||||
def _shared_library_path(library) -> Path:
|
||||
"""Returns the absolute path to `library`
|
||||
|
||||
This function is cross-platform and works for Linux, MacOS and Windows.
|
||||
Raises a RuntimeError if the library path cannot be retrieved
|
||||
|
||||
"""
|
||||
# pylint: disable=protected-access
|
||||
path = pathlib.Path(library._name).resolve()
|
||||
if path.is_file():
|
||||
return path
|
||||
|
||||
try:
|
||||
# Linux or MacOS only, ImportError on Windows
|
||||
return pathlib.Path(dlinfo.DLInfo(library).path).resolve()
|
||||
except (Exception, ImportError): # pragma: nocover
|
||||
raise RuntimeError(
|
||||
f'failed to retrieve the path to {library} library') from None
|
||||
|
||||
def info(self):
|
||||
"""Bindings to espeak_Info
|
||||
|
||||
Returns
|
||||
-------
|
||||
version, data_path: encoded strings containing the espeak version
|
||||
number and data path respectively
|
||||
|
||||
"""
|
||||
f_info = self._library.espeak_Info
|
||||
f_info.restype = ctypes.c_char_p
|
||||
data_path = ctypes.c_char_p()
|
||||
version = f_info(ctypes.byref(data_path))
|
||||
return version, data_path.value
|
||||
|
||||
def list_voices(self, name):
|
||||
"""Bindings to espeak_ListVoices
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name (str or None): if specified, a filter on voices to be listed
|
||||
|
||||
Returns
|
||||
-------
|
||||
voices: a pointer to EspeakVoice.Struct instances
|
||||
|
||||
"""
|
||||
f_list_voices = self._library.espeak_ListVoices
|
||||
f_list_voices.argtypes = [ctypes.POINTER(EspeakVoice.VoiceStruct)]
|
||||
f_list_voices.restype = ctypes.POINTER(
|
||||
ctypes.POINTER(EspeakVoice.VoiceStruct))
|
||||
return f_list_voices(name)
|
||||
|
||||
def set_voice_by_name(self, name) -> int:
|
||||
"""Bindings to espeak_SetVoiceByName
|
||||
|
||||
Parameters
|
||||
----------
|
||||
name (str) : the voice name to setup
|
||||
|
||||
Returns
|
||||
-------
|
||||
0 on success, non-zero integer on failure
|
||||
|
||||
"""
|
||||
f_set_voice_by_name = self._library.espeak_SetVoiceByName
|
||||
f_set_voice_by_name.argtypes = [ctypes.c_char_p]
|
||||
return f_set_voice_by_name(name)
|
||||
|
||||
def get_current_voice(self):
|
||||
"""Bindings to espeak_GetCurrentVoice
|
||||
|
||||
Returns
|
||||
-------
|
||||
a EspeakVoice.Struct instance or None if no voice has been setup
|
||||
|
||||
"""
|
||||
f_get_current_voice = self._library.espeak_GetCurrentVoice
|
||||
f_get_current_voice.restype = ctypes.POINTER(EspeakVoice.VoiceStruct)
|
||||
return f_get_current_voice().contents
|
||||
|
||||
def text_to_phonemes(self, text_ptr, text_mode, phonemes_mode):
|
||||
"""Bindings to espeak_TextToPhonemes
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text_ptr (pointer): the text to be phonemized, as a pointer to a
|
||||
pointer of chars
|
||||
text_mode (bits field): see espeak sources for details
|
||||
phonemes_mode (bits field): see espeak sources for details
|
||||
|
||||
Returns
|
||||
-------
|
||||
an encoded string containing the computed phonemes
|
||||
|
||||
"""
|
||||
f_text_to_phonemes = self._library.espeak_TextToPhonemes
|
||||
f_text_to_phonemes.restype = ctypes.c_char_p
|
||||
f_text_to_phonemes.argtypes = [
|
||||
ctypes.POINTER(ctypes.c_char_p),
|
||||
ctypes.c_int,
|
||||
ctypes.c_int]
|
||||
return f_text_to_phonemes(text_ptr, text_mode, phonemes_mode)
|
||||
|
||||
def set_phoneme_trace(self, mode, file_pointer):
|
||||
""""Bindings on espeak_SetPhonemeTrace
|
||||
|
||||
This method must be called before any call to synthetize()
|
||||
|
||||
Parameters
|
||||
----------
|
||||
mode (bits field): see espeak sources for details
|
||||
file_pointer (FILE*): a pointer to an opened file in which to output
|
||||
the phoneme trace
|
||||
|
||||
"""
|
||||
f_set_phoneme_trace = self._library.espeak_SetPhonemeTrace
|
||||
f_set_phoneme_trace.argtypes = [
|
||||
ctypes.c_int,
|
||||
ctypes.c_void_p]
|
||||
f_set_phoneme_trace(mode, file_pointer)
|
||||
|
||||
def synthetize(self, text_ptr, size, mode):
|
||||
"""Bindings on espeak_Synth
|
||||
|
||||
The output phonemes are sent to the file specified by a call to
|
||||
set_phoneme_trace().
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text (pointer) : a pointer to chars
|
||||
size (int) : number of chars in `text`
|
||||
mode (bits field) : see espeak sources for details
|
||||
|
||||
Returns
|
||||
-------
|
||||
0 on success, non-zero integer on failure
|
||||
|
||||
"""
|
||||
f_synthetize = self._library.espeak_Synth
|
||||
f_synthetize.argtypes = [
|
||||
ctypes.c_void_p,
|
||||
ctypes.c_size_t,
|
||||
ctypes.c_uint,
|
||||
ctypes.c_int, # position_type
|
||||
ctypes.c_uint,
|
||||
ctypes.POINTER(ctypes.c_uint),
|
||||
ctypes.c_void_p]
|
||||
return f_synthetize(text_ptr, size, 0, 1, 0, mode, None, None)
|
||||
@@ -0,0 +1,113 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Base class of espeak backends for the phonemizer"""
|
||||
|
||||
import abc
|
||||
from logging import Logger
|
||||
from typing import Optional, Union, Pattern
|
||||
|
||||
from phonemizer.backend.base import BaseBackend
|
||||
from phonemizer.backend.espeak.wrapper import EspeakWrapper
|
||||
from phonemizer.logger import get_logger
|
||||
from phonemizer.punctuation import Punctuation
|
||||
from phonemizer.separator import Separator
|
||||
|
||||
|
||||
class BaseEspeakBackend(BaseBackend):
|
||||
"""Abstract espeak backend for the phonemizer
|
||||
|
||||
Base class of the concrete backends Espeak and EspeakMbrola. It provides
|
||||
facilities to find espeak library and read espeak version.
|
||||
|
||||
"""
|
||||
def __init__(self, language: str,
|
||||
punctuation_marks: Optional[Union[str, Pattern]] = None,
|
||||
preserve_punctuation: bool = False,
|
||||
logger: Optional[Logger] = None):
|
||||
super().__init__(
|
||||
language,
|
||||
punctuation_marks=punctuation_marks,
|
||||
preserve_punctuation=preserve_punctuation,
|
||||
logger=logger)
|
||||
|
||||
self._espeak = EspeakWrapper()
|
||||
self.logger.debug('loaded %s', self._espeak.library_path)
|
||||
|
||||
|
||||
@classmethod
|
||||
def set_library(cls, library):
|
||||
"""Sets the espeak backend to use `library`
|
||||
|
||||
If this is not set, the backend uses the default espeak shared library
|
||||
from the system installation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
library (str or None) : the path to the espeak shared library to use as
|
||||
backend. Set `library` to None to restore the default.
|
||||
|
||||
"""
|
||||
EspeakWrapper.set_library(library)
|
||||
|
||||
@classmethod
|
||||
def library(cls):
|
||||
"""Returns the espeak library used as backend
|
||||
|
||||
The following precedence rule applies for library lookup:
|
||||
|
||||
1. As specified by BaseEspeakBackend.set_library()
|
||||
2. Or as specified by the environment variable
|
||||
PHONEMIZER_ESPEAK_LIBRARY
|
||||
3. Or the default espeak library found on the system
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError if the espeak library cannot be found or if the
|
||||
environment variable PHONEMIZER_ESPEAK_LIBRARY is set to a
|
||||
non-readable file
|
||||
|
||||
"""
|
||||
return EspeakWrapper.library()
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
try:
|
||||
EspeakWrapper()
|
||||
except RuntimeError: # pragma: nocover
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def is_espeak_ng(cls) -> bool:
|
||||
"""Returns True if using espeak-ng, False otherwise"""
|
||||
# espeak-ng starts with version 1.49
|
||||
return cls.version() >= (1, 49)
|
||||
|
||||
@classmethod
|
||||
def version(cls):
|
||||
"""Espeak version as a tuple (major, minor, patch)
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError if BaseEspeakBackend.is_available() is False or if the
|
||||
version cannot be extracted for some reason.
|
||||
|
||||
"""
|
||||
return EspeakWrapper().version
|
||||
|
||||
@abc.abstractmethod
|
||||
def _postprocess_line(self, line: str, num: int,
|
||||
separator: Separator, strip: bool) -> str:
|
||||
pass
|
||||
@@ -0,0 +1,172 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Espeak backend for the phonemizer"""
|
||||
|
||||
import itertools
|
||||
import re
|
||||
from logging import Logger
|
||||
from typing import Optional, Tuple, List, Union, Pattern
|
||||
|
||||
from phonemizer.backend.espeak.base import BaseEspeakBackend
|
||||
from phonemizer.backend.espeak.language_switch import (
|
||||
get_language_switch_processor, LanguageSwitch, BaseLanguageSwitch)
|
||||
from phonemizer.backend.espeak.words_mismatch import (
|
||||
get_words_mismatch_processor, WordMismatch, BaseWordsMismatch)
|
||||
from phonemizer.backend.espeak.wrapper import EspeakWrapper
|
||||
from phonemizer.separator import Separator
|
||||
|
||||
|
||||
class EspeakBackend(BaseEspeakBackend):
|
||||
"""Espeak backend for the phonemizer"""
|
||||
# a regular expression to find phonemes stresses in espeak output
|
||||
_ESPEAK_STRESS_RE = re.compile(r"[ˈˌ'-]+")
|
||||
|
||||
# pylint: disable=too-many-arguments
|
||||
def __init__(self, language: str,
|
||||
punctuation_marks: Optional[Union[str, Pattern]] = None,
|
||||
preserve_punctuation: bool = False,
|
||||
with_stress: bool = False,
|
||||
tie: Union[bool, str] = False,
|
||||
language_switch: LanguageSwitch = 'keep-flags',
|
||||
words_mismatch: WordMismatch = 'ignore',
|
||||
logger: Optional[Logger] = None):
|
||||
super().__init__(
|
||||
language, punctuation_marks=punctuation_marks,
|
||||
preserve_punctuation=preserve_punctuation, logger=logger)
|
||||
|
||||
self._espeak.set_voice(language)
|
||||
self._with_stress = with_stress
|
||||
self._tie = self._init_tie(tie)
|
||||
self._lang_switch: BaseLanguageSwitch = get_language_switch_processor(
|
||||
language_switch, self.logger, self.language)
|
||||
self._words_mismatch: BaseWordsMismatch = get_words_mismatch_processor(
|
||||
words_mismatch, self.logger)
|
||||
|
||||
@staticmethod
|
||||
def _init_tie(tie) -> Optional[str]:
|
||||
if not tie:
|
||||
return None
|
||||
|
||||
if tie is True: # default U+361 tie character
|
||||
return '͡'
|
||||
|
||||
# non default tie charcacter
|
||||
tie = str(tie)
|
||||
if len(tie) != 1:
|
||||
raise RuntimeError(
|
||||
f'explicit tie must be a single charcacter but is {tie}')
|
||||
return tie
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'espeak'
|
||||
|
||||
@classmethod
|
||||
def supported_languages(cls):
|
||||
return {
|
||||
voice.language: voice.name
|
||||
for voice in EspeakWrapper().available_voices()}
|
||||
|
||||
def _phonemize_aux(self, text, offset, separator, strip):
|
||||
if self._tie is not None and separator.phone:
|
||||
self.logger.warning(
|
||||
'cannot use ties AND phone separation, '
|
||||
'ignoring phone separator')
|
||||
|
||||
output = []
|
||||
lang_switches = []
|
||||
for num, line in enumerate(text, start=1):
|
||||
line = self._espeak.text_to_phonemes(line, self._tie)
|
||||
line, has_switch = self._postprocess_line(
|
||||
line, num, separator, strip)
|
||||
output.append(line)
|
||||
if has_switch:
|
||||
lang_switches.append(num + offset)
|
||||
|
||||
return output, lang_switches
|
||||
|
||||
def _process_stress(self, word):
|
||||
if self._with_stress:
|
||||
return word
|
||||
# remove the stresses on phonemes
|
||||
return re.sub(self._ESPEAK_STRESS_RE, '', word)
|
||||
|
||||
def _process_tie(self, word: str, separator: Separator):
|
||||
# NOTE a bug in espeak append ties to (en) flags so as (͡e͡n).
|
||||
# We do not correct it here.
|
||||
if self._tie is not None and self._tie != '͡':
|
||||
# replace default '͡' by the requested one
|
||||
return word.replace('͡', self._tie)
|
||||
return word.replace('_', separator.phone)
|
||||
|
||||
def _postprocess_line(self, line: str, num: int,
|
||||
separator: Separator, strip: bool) -> Tuple[str, bool]:
|
||||
# espeak can split an utterance into several lines because
|
||||
# of punctuation, here we merge the lines into a single one
|
||||
line = line.strip().replace('\n', ' ').replace(' ', ' ')
|
||||
|
||||
# due to a bug in espeak-ng, some additional separators can be
|
||||
# added at the end of a word. Here a quick fix to solve that
|
||||
# issue. See https://github.com/espeak-ng/espeak-ng/issues/694
|
||||
line = re.sub(r'_+', '_', line)
|
||||
line = re.sub(r'_ ', ' ', line)
|
||||
|
||||
line, has_switch = self._lang_switch.process(line)
|
||||
if not line:
|
||||
return '', has_switch
|
||||
|
||||
out_line = ''
|
||||
for word in line.split(' '):
|
||||
word = self._process_stress(word.strip())
|
||||
if not strip and self._tie is None:
|
||||
word += '_'
|
||||
word = self._process_tie(word, separator)
|
||||
out_line += word + separator.word
|
||||
|
||||
if strip and separator.word:
|
||||
# erase the last word separator from the line
|
||||
out_line = out_line[:-len(separator.word)]
|
||||
|
||||
return out_line, has_switch
|
||||
|
||||
def _phonemize_preprocess(self, text: List[str]) -> Tuple[Union[str, List[str]], List]:
|
||||
text, punctuation_marks = super()._phonemize_preprocess(text)
|
||||
self._words_mismatch.count_text(text)
|
||||
return text, punctuation_marks
|
||||
|
||||
def _phonemize_postprocess(self, phonemized, punctuation_marks, separator: Separator, strip: bool):
|
||||
text = phonemized[0]
|
||||
switches = phonemized[1]
|
||||
|
||||
self._words_mismatch.count_phonemized(text, separator)
|
||||
self._lang_switch.warning(switches)
|
||||
|
||||
phonemized = super()._phonemize_postprocess(text, punctuation_marks, separator, strip)
|
||||
return self._words_mismatch.process(phonemized)
|
||||
|
||||
@staticmethod
|
||||
def _flatten(phonemized) -> List:
|
||||
"""Specialization of BaseBackend._flatten for the espeak backend
|
||||
|
||||
From [([1, 2], ['a', 'b']), ([3],), ([4], ['c'])] to [[1, 2, 3, 4],
|
||||
['a', 'b', 'c']].
|
||||
|
||||
"""
|
||||
flattened = []
|
||||
for i in range(len(phonemized[0])):
|
||||
flattened.append(
|
||||
list(itertools.chain(
|
||||
c for chunk in phonemized for c in chunk[i])))
|
||||
return flattened
|
||||
+193
@@ -0,0 +1,193 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Manages language switches for the espeak backend
|
||||
|
||||
This module is used in phonemizer.backend.EspeakBackend and should be
|
||||
considered private.
|
||||
|
||||
It manages languages switches that occur during phonemization, where a part of
|
||||
a text is phonemized in a language different from the target language. For
|
||||
instance the sentence "j'aime le football" in French will be phonemized by
|
||||
espeak as "ʒɛm lə (en)fʊtbɔːl(fr)", "football" be pronounced as an English
|
||||
word. This may cause two issues to end users. First it introduces undesirable
|
||||
(.) language switch flags. It may introduce extra phones that are not present
|
||||
in the target language phoneset.
|
||||
|
||||
This module implements 3 alternative solutions the user can choose when
|
||||
initializing the espeak backend:
|
||||
- 'keep-flags' preserves the language switch flags,
|
||||
- 'remove-flags' removes the flags (.) but preserves the words with alternative
|
||||
phoneset,
|
||||
- 'remove-utterance' removes the utterances where flags are detected.
|
||||
|
||||
"""
|
||||
|
||||
import abc
|
||||
import re
|
||||
from logging import Logger
|
||||
from typing import List, Tuple
|
||||
from typing_extensions import TypeAlias, Literal
|
||||
|
||||
LanguageSwitch: TypeAlias = Literal['keep-flags', 'remove-flags', 'remove-utterance']
|
||||
|
||||
|
||||
def get_language_switch_processor(mode: LanguageSwitch, logger: Logger, language: str) -> 'BaseLanguageSwitch':
|
||||
"""Returns a language switch processor initialized from `mode`
|
||||
|
||||
The `mode` can be one of the following:
|
||||
- 'keep-flags' to preserve the switch flags
|
||||
- 'remove-flags' to suppress the switch flags
|
||||
- 'remove-utterance' to suppress the entire utterance
|
||||
|
||||
Raises a RuntimeError if the `mode` is unknown.
|
||||
|
||||
"""
|
||||
processors = {
|
||||
'keep-flags': KeepFlags,
|
||||
'remove-flags': RemoveFlags,
|
||||
'remove-utterance': RemoveUtterances}
|
||||
|
||||
try:
|
||||
return processors[mode](logger, language)
|
||||
except KeyError:
|
||||
raise RuntimeError(
|
||||
f'mode "{mode}" invalid, must be in {", ".join(processors.keys())}'
|
||||
) from None
|
||||
|
||||
|
||||
class BaseLanguageSwitch(abc.ABC):
|
||||
"""The base class for language switch processors
|
||||
|
||||
Parameters
|
||||
----------
|
||||
logger (logging.Logger) : a logger instance to send warnings when language
|
||||
switches are detected.
|
||||
language (str) : the language code currently in use by the phonemizer, to
|
||||
customize warning content
|
||||
|
||||
"""
|
||||
# a regular expression to find language switch flags in espeak output,
|
||||
# Switches have the following form (here a switch from English to French):
|
||||
# "something (fr)quelque chose(en) another thing".
|
||||
_ESPEAK_FLAGS_RE = re.compile(r'\(.+?\)')
|
||||
|
||||
def __init__(self, logger: Logger, language: str):
|
||||
self._logger = logger
|
||||
self._language = language
|
||||
|
||||
@classmethod
|
||||
def is_language_switch(cls, utterance: str) -> bool:
|
||||
"""Returns True is a language switch is present in the `utterance`"""
|
||||
return bool(cls._ESPEAK_FLAGS_RE.search(utterance))
|
||||
|
||||
@classmethod
|
||||
@abc.abstractmethod
|
||||
def process(cls, utterance: str) -> Tuple[str, bool]:
|
||||
"""Detects and process language switches according to the mode
|
||||
|
||||
This method is called on each utterance as a phonemization
|
||||
post-processing step.
|
||||
|
||||
Returns
|
||||
-------
|
||||
processed_utterance (str) : the utterance either preserved, deleted (as
|
||||
'') or with the switch removed
|
||||
has_switch (bool): True if a language switch flag is found in the
|
||||
`utterance` and False otherwise
|
||||
|
||||
"""
|
||||
|
||||
@abc.abstractmethod
|
||||
def warning(self, switches: List[int]):
|
||||
"""Sends warnings to the logger with recorded language switches
|
||||
|
||||
This method is called a single time at the very end of the
|
||||
phonemization process.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
switches (list of int) : the line numbers where language switches has
|
||||
been detected during phonemization
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class KeepFlags(BaseLanguageSwitch):
|
||||
"""Preserves utterances even if language switch flags are present"""
|
||||
|
||||
@classmethod
|
||||
def process(cls, utterance: str) -> Tuple[str, bool]:
|
||||
return utterance, cls.is_language_switch(utterance)
|
||||
|
||||
def warning(self, switches: List[int]):
|
||||
if not switches:
|
||||
return
|
||||
|
||||
nswitches = len(switches)
|
||||
self._logger.warning(
|
||||
'%s utterances containing language switches '
|
||||
'on lines %s', nswitches,
|
||||
', '.join(str(switch) for switch in sorted(switches)))
|
||||
self._logger.warning(
|
||||
'extra phones may appear in the "%s" phoneset', self._language)
|
||||
self._logger.warning(
|
||||
'language switch flags have been kept '
|
||||
'(applying "keep-flags" policy)')
|
||||
|
||||
|
||||
class RemoveFlags(BaseLanguageSwitch):
|
||||
"""Removes the language switch flags when detected"""
|
||||
|
||||
@classmethod
|
||||
def process(cls, utterance: str) -> Tuple[str, bool]:
|
||||
if cls.is_language_switch(utterance):
|
||||
# remove all the (lang) flags in the current utterance
|
||||
return re.sub(cls._ESPEAK_FLAGS_RE, '', utterance), True
|
||||
return utterance, False
|
||||
|
||||
def warning(self, switches: List[int]):
|
||||
if not switches:
|
||||
return
|
||||
|
||||
nswitches = len(switches)
|
||||
self._logger.warning(
|
||||
'%s utterances containing language switches '
|
||||
'on lines %s', nswitches,
|
||||
', '.join(str(switch) for switch in sorted(switches)))
|
||||
self._logger.warning(
|
||||
'extra phones may appear in the "%s" phoneset', self._language)
|
||||
self._logger.warning(
|
||||
'language switch flags have been removed '
|
||||
'(applying "remove-flags" policy)')
|
||||
|
||||
|
||||
class RemoveUtterances(BaseLanguageSwitch):
|
||||
"""Remove the entire utterance when a language switch flag is detected"""
|
||||
|
||||
@classmethod
|
||||
def process(cls, utterance: str) -> Tuple[str, bool]:
|
||||
if cls.is_language_switch(utterance):
|
||||
# drop the entire utterance
|
||||
return '', True
|
||||
return utterance, False
|
||||
|
||||
def warning(self, switches: List[int]):
|
||||
if not switches:
|
||||
return
|
||||
|
||||
nswitches = len(switches)
|
||||
self._logger.warning(
|
||||
'removed %s utterances containing language switches '
|
||||
'(applying "remove-utterance" policy)', nswitches)
|
||||
@@ -0,0 +1,108 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Mbrola backend for the phonemizer"""
|
||||
|
||||
import pathlib
|
||||
import shutil
|
||||
import sys
|
||||
from logging import Logger
|
||||
from pathlib import Path
|
||||
from typing import Union, Optional, List, Dict
|
||||
|
||||
from phonemizer.backend.espeak.base import BaseEspeakBackend
|
||||
from phonemizer.backend.espeak.wrapper import EspeakWrapper
|
||||
from phonemizer.separator import Separator
|
||||
|
||||
|
||||
class EspeakMbrolaBackend(BaseEspeakBackend):
|
||||
"""Espeak-mbrola backend for the phonemizer"""
|
||||
# this will be initialized once, at the first call to supported_languages()
|
||||
_supported_languages = None
|
||||
|
||||
def __init__(self, language: str, logger: Optional[Logger] = None):
|
||||
super().__init__(language, logger=logger)
|
||||
self._espeak.set_voice(language)
|
||||
|
||||
@staticmethod
|
||||
def name():
|
||||
return 'espeak-mbrola'
|
||||
|
||||
@classmethod
|
||||
def is_available(cls) -> bool:
|
||||
"""Mbrola backend is available for espeak>=1.49"""
|
||||
return (
|
||||
BaseEspeakBackend.is_available() and
|
||||
shutil.which('mbrola') and
|
||||
BaseEspeakBackend.is_espeak_ng())
|
||||
|
||||
@classmethod
|
||||
def _all_supported_languages(cls):
|
||||
# retrieve the mbrola voices. This voices must be installed separately.
|
||||
voices = EspeakWrapper().available_voices('mbrola')
|
||||
return {voice.identifier[3:]: voice.name for voice in voices}
|
||||
|
||||
@classmethod
|
||||
def _is_language_installed(cls, language: str, data_path: Union[str, Path]) \
|
||||
-> bool:
|
||||
"""Returns True if the required mbrola voice is installed"""
|
||||
# this is a reimplementation of LoadMbrolaTable from espeak
|
||||
# synth_mbrola.h sources
|
||||
voice = language[3:] # remove mb- prefix
|
||||
|
||||
if pathlib.Path(data_path / 'mbrola' / voice).is_file():
|
||||
return True # pragma: nocover
|
||||
|
||||
if sys.platform != 'win32':
|
||||
candidates = [
|
||||
f'/usr/share/mbrola/{voice}',
|
||||
f'/usr/share/mbrola/{voice}/{voice}',
|
||||
f'/usr/share/mbrola/voices/{voice}']
|
||||
for candidate in candidates:
|
||||
if pathlib.Path(candidate).is_file():
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@classmethod
|
||||
def supported_languages(cls) -> Dict[str, str]: # pragma: nocover
|
||||
"""Returns the list of installed mbrola voices"""
|
||||
if cls._supported_languages is None:
|
||||
data_path = EspeakWrapper().data_path
|
||||
cls._supported_languages = {
|
||||
k: v for k, v in cls._all_supported_languages().items()
|
||||
if cls._is_language_installed(k, data_path)}
|
||||
return cls._supported_languages
|
||||
|
||||
def _phonemize_aux(self, text: List[str], offset: int,
|
||||
separator: Separator, strip: bool) -> List[str]:
|
||||
output = []
|
||||
for num, line in enumerate(text, start=1):
|
||||
line = self._espeak.synthetize(line)
|
||||
line = self._postprocess_line(line, offset + num, separator, strip)
|
||||
output.append(line)
|
||||
return output
|
||||
|
||||
def _postprocess_line(self, line: str, num: int,
|
||||
separator: Separator, strip: bool) -> str:
|
||||
# retrieve the phonemes with the correct SAMPA alphabet (but
|
||||
# without word separation)
|
||||
phonemes = (
|
||||
phn.split('\t')[0] for phn in line.split('\n') if phn.strip())
|
||||
phonemes = separator.phone.join(pho for pho in phonemes if pho != '_')
|
||||
|
||||
if not strip:
|
||||
phonemes += separator.phone
|
||||
|
||||
return phonemes
|
||||
@@ -0,0 +1,81 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Voice struct from Espeak API exposed to Python"""
|
||||
|
||||
import ctypes
|
||||
|
||||
|
||||
# This class can be a dataclass for compatibility with python-3.6 we don't use
|
||||
# the dataclasses module.
|
||||
class EspeakVoice:
|
||||
"""A helper class to expose voice structures within C and Python"""
|
||||
|
||||
def __init__(self, name: str = '', language: str = '', identifier: str = ''):
|
||||
self._name = name
|
||||
self._language = language
|
||||
self._identifier = identifier
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
"""Voice name"""
|
||||
return self._name
|
||||
|
||||
@property
|
||||
def language(self):
|
||||
"""Language code"""
|
||||
return self._language
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
"""Path to the voice file wrt espeak data path"""
|
||||
return self._identifier
|
||||
|
||||
def __eq__(self, other: 'EspeakVoice'):
|
||||
return (
|
||||
self.name == other.name and
|
||||
self.language == other.language and
|
||||
self.identifier == other.identifier)
|
||||
|
||||
def __hash__(self):
|
||||
return hash((self.name, self.language, self.identifier))
|
||||
|
||||
class VoiceStruct(ctypes.Structure): # pylint: disable=too-few-public-methods
|
||||
"""A helper class to fetch voices information from the espeak library.
|
||||
|
||||
The espeak_VOICE struct is defined in speak_lib.h from the espeak code.
|
||||
Here we use only name (voice name), languages (language code) and
|
||||
identifier (voice file) information.
|
||||
|
||||
"""
|
||||
_fields_ = [
|
||||
('name', ctypes.c_char_p),
|
||||
('languages', ctypes.c_char_p),
|
||||
('identifier', ctypes.c_char_p)]
|
||||
|
||||
def to_ctypes(self):
|
||||
"""Converts the Voice instance to an espeak ctypes structure"""
|
||||
return self.VoiceStruct(
|
||||
self.name.encode('utf8') if self.name else None,
|
||||
self.language.encode('utf8') if self.language else None,
|
||||
self.identifier.encode('utf8') if self.identifier else None)
|
||||
|
||||
@classmethod
|
||||
def from_ctypes(cls, struct: VoiceStruct):
|
||||
"""Returns a Voice instance built from an espeak ctypes structure"""
|
||||
return cls(
|
||||
name=(struct.name or b'').decode(),
|
||||
# discard a useless char prepended by espeak
|
||||
language=(struct.languages or b'0').decode()[1:],
|
||||
identifier=(struct.identifier or b'').decode())
|
||||
+152
@@ -0,0 +1,152 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Manages words count mismatches for the espeak backend"""
|
||||
|
||||
import abc
|
||||
import re
|
||||
from logging import Logger
|
||||
from typing import List, Tuple
|
||||
|
||||
from typing_extensions import TypeAlias, Literal, Union
|
||||
|
||||
from phonemizer.separator import Separator
|
||||
|
||||
|
||||
WordMismatch: TypeAlias = Literal["warn", "ignore"]
|
||||
|
||||
|
||||
def get_words_mismatch_processor(mode: WordMismatch, logger: Logger) -> 'BaseWordsMismatch':
|
||||
"""Returns a word count mismatch processor according to `mode`
|
||||
|
||||
The `mode` can be one of the following:
|
||||
- `ignore` to ignore words mismatches
|
||||
- `warn` to display a warning on each mismatched utterance
|
||||
- `remove` to remove any utterance containing a words mismatch
|
||||
|
||||
Raises a RuntimeError if the `mode` is unknown.
|
||||
|
||||
"""
|
||||
processors = {
|
||||
'ignore': Ignore,
|
||||
'warn': Warn,
|
||||
'remove': Remove}
|
||||
|
||||
try:
|
||||
return processors[mode](logger)
|
||||
except KeyError:
|
||||
raise RuntimeError(
|
||||
f'mode {mode} invalid, must be in {", ".join(processors.keys())}'
|
||||
) from None
|
||||
|
||||
|
||||
class BaseWordsMismatch(abc.ABC):
|
||||
"""The base class of all word count mismatch processors"""
|
||||
_RE_SPACES = re.compile(r'\s+')
|
||||
|
||||
def __init__(self, logger: Logger):
|
||||
self._logger = logger
|
||||
self._count_txt = []
|
||||
self._count_phn = []
|
||||
|
||||
@classmethod
|
||||
def _count_words(
|
||||
cls,
|
||||
text: List[str],
|
||||
wordsep: Union[str, re.Pattern] = _RE_SPACES) -> List[int]:
|
||||
"""Return the number of words contained in each line of `text`"""
|
||||
if not isinstance(wordsep, re.Pattern):
|
||||
wordsep = re.escape(wordsep)
|
||||
|
||||
return [
|
||||
len([w for w in re.split(wordsep, line.strip()) if w])
|
||||
for line in text]
|
||||
|
||||
def _mismatched_lines(self) -> List[Tuple[int, int, int]]:
|
||||
"""Returns a list of (num_line, nwords_input, nwords_output)
|
||||
|
||||
Consider only the lines where nwords_input != nwords_output. Raises a
|
||||
RuntimeError if input and output do not have the same number of lines.
|
||||
|
||||
"""
|
||||
if len(self._count_txt) != len(self._count_phn):
|
||||
raise RuntimeError( # pragma: nocover
|
||||
f'number of lines in input and output must be equal, '
|
||||
f'we have: input={len(self._count_txt)}, '
|
||||
f'output={len(self._count_phn)}')
|
||||
|
||||
return [
|
||||
(n, t, p) for n, (t, p) in
|
||||
enumerate(zip(self._count_txt, self._count_phn))
|
||||
if t != p]
|
||||
|
||||
def _resume(self, nmismatch: int, nlines: int):
|
||||
"""Logs a high level undetailed warning"""
|
||||
if nmismatch:
|
||||
self._logger.warning(
|
||||
'words count mismatch on %s%% of the lines (%s/%s)',
|
||||
round(nmismatch / nlines, 2) * 100, nmismatch, nlines)
|
||||
|
||||
def count_text(self, text: List[str]):
|
||||
"""Stores the number of words in each input line"""
|
||||
self._count_txt = self._count_words(text)
|
||||
|
||||
def count_phonemized(self, text: List[str], separator: Separator):
|
||||
"""Stores the number of words in each output line"""
|
||||
self._count_phn = self._count_words(text, separator.word)
|
||||
|
||||
@abc.abstractmethod
|
||||
def process(self, text: List[str]) -> List[str]:
|
||||
"""Detects and process word count misatches according to the mode
|
||||
|
||||
This method is called at the very end of phonemization, during
|
||||
post-processing.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
class Ignore(BaseWordsMismatch):
|
||||
"""Ignores word count mismatches"""
|
||||
|
||||
def process(self, text: List[str]) -> List[str]:
|
||||
self._resume(len(self._mismatched_lines()), len(text))
|
||||
return text
|
||||
|
||||
|
||||
class Warn(BaseWordsMismatch):
|
||||
"""Warns on every mismatch detected"""
|
||||
|
||||
def process(self, text: List[str]) -> List[str]:
|
||||
mismatch = self._mismatched_lines()
|
||||
for num, ntxt, nphn in mismatch:
|
||||
self._logger.warning(
|
||||
'words count mismatch on line %s '
|
||||
'(expected %s words but get %s)',
|
||||
num + 1, ntxt, nphn)
|
||||
|
||||
self._resume(len(mismatch), len(text))
|
||||
return text
|
||||
|
||||
|
||||
class Remove(BaseWordsMismatch):
|
||||
"""Removes any utterance containing a word count mismatch"""
|
||||
|
||||
def process(self, text: List[str]) -> List[str]:
|
||||
mismatch = [line[0] for line in self._mismatched_lines()]
|
||||
self._resume(len(mismatch), len(text))
|
||||
self._logger.warning('removing the mismatched lines')
|
||||
|
||||
for index in mismatch:
|
||||
text[index] = ''
|
||||
return text
|
||||
@@ -0,0 +1,370 @@
|
||||
# Copyright 2015-2021 Mathieu Bernard
|
||||
#
|
||||
# This file is part of phonemizer: you can redistribute it and/or
|
||||
# modify it under the terms of the GNU General Public License as
|
||||
# published by the Free Software Foundation, either version 3 of the
|
||||
# License, or (at your option) any later version.
|
||||
#
|
||||
# Phonemizer is distributed in the hope that it will be useful, but
|
||||
# WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
|
||||
# General Public License for more details.
|
||||
#
|
||||
# You should have received a copy of the GNU General Public License
|
||||
# along with phonemizer. If not, see <http://www.gnu.org/licenses/>.
|
||||
"""Wrapper on espeak-ng library"""
|
||||
|
||||
import ctypes
|
||||
import ctypes.util
|
||||
import functools
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import tempfile
|
||||
import weakref
|
||||
from typing import Tuple, Dict
|
||||
|
||||
from phonemizer.backend.espeak.api import EspeakAPI
|
||||
from phonemizer.backend.espeak.voice import EspeakVoice
|
||||
|
||||
|
||||
class EspeakWrapper:
|
||||
"""Wrapper on espeak shared library
|
||||
|
||||
The aim of this wrapper is not to be exhaustive but to encapsulate the
|
||||
espeak functions required for phonemization. It relies on a espeak shared
|
||||
library (*.so on Linux, *.dylib on Mac and *.dll on Windows) that must be
|
||||
installed on the system.
|
||||
|
||||
Use the function `EspeakWrapper.set_library()` before instanciation to
|
||||
customize the library to use.
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError if the espeak shared library cannot be loaded
|
||||
|
||||
"""
|
||||
# a static variable used to overload the default espeak library installed
|
||||
# on the system. The user can choose an alternative espeak library with
|
||||
# the method EspeakWrapper.set_library().
|
||||
_ESPEAK_LIBRARY = None
|
||||
|
||||
def __init__(self):
|
||||
# the following attributes are accessed through properties and are
|
||||
# lazily initialized
|
||||
self._version: Tuple[int, ...] = None
|
||||
self._data_path = None
|
||||
self._voice = None
|
||||
|
||||
# load the espeak API
|
||||
self._espeak = EspeakAPI(self.library())
|
||||
|
||||
# lazy loading of attributes only required for the synthetize method
|
||||
self._libc_ = None
|
||||
self._tempfile_ = None
|
||||
|
||||
@property
|
||||
def _libc(self):
|
||||
if self._libc_ is None:
|
||||
self._libc_ = (
|
||||
ctypes.windll.msvcrt if sys.platform == 'win32' else
|
||||
ctypes.cdll.LoadLibrary(ctypes.util.find_library('c')))
|
||||
return self._libc_
|
||||
|
||||
@property
|
||||
def _tempfile(self):
|
||||
if self._tempfile_ is None:
|
||||
# this will automatically removed at exit
|
||||
# pylint: disable=consider-using-with
|
||||
self._tempfile_ = tempfile.NamedTemporaryFile()
|
||||
weakref.finalize(self._tempfile_, self._tempfile_.close)
|
||||
return self._tempfile_
|
||||
|
||||
def __getstate__(self):
|
||||
"""For pickling, when phonemizing on multiple jobs"""
|
||||
return {
|
||||
'version': self._version,
|
||||
'data_path': self._data_path,
|
||||
'voice': self._voice}
|
||||
|
||||
def __setstate__(self, state: Dict):
|
||||
"""For unpickling, when phonemizing on multiple jobs"""
|
||||
self.__init__()
|
||||
self._version = state['version']
|
||||
self._data_path = state['data_path']
|
||||
self._voice = state['voice']
|
||||
if self._voice:
|
||||
if 'mb' in self._voice.identifier: # mbrola voice
|
||||
self.set_voice(self._voice.identifier[3:])
|
||||
else:
|
||||
self.set_voice(self._voice.language)
|
||||
|
||||
@classmethod
|
||||
def set_library(cls, library: str):
|
||||
"""Sets the espeak backend to use `library`
|
||||
|
||||
If this is not set, the backend uses the default espeak shared library
|
||||
from the system installation.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
library (str or None) : the path to the espeak shared library to use as
|
||||
backend. Set `library` to None to restore the default.
|
||||
|
||||
"""
|
||||
cls._ESPEAK_LIBRARY = library
|
||||
|
||||
@classmethod
|
||||
def library(cls):
|
||||
"""Returns the espeak library used as backend
|
||||
|
||||
The following precedence rule applies for library lookup:
|
||||
|
||||
1. As specified by BaseEspeakBackend.set_library()
|
||||
2. Or as specified by the environment variable
|
||||
PHONEMIZER_ESPEAK_LIBRARY
|
||||
3. Or the default espeak library found on the system
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError if the espeak library cannot be found or if the
|
||||
environment variable PHONEMIZER_ESPEAK_LIBRARY is set to a
|
||||
non-readable file
|
||||
|
||||
"""
|
||||
if cls._ESPEAK_LIBRARY:
|
||||
return cls._ESPEAK_LIBRARY
|
||||
|
||||
if 'PHONEMIZER_ESPEAK_LIBRARY' in os.environ:
|
||||
library = pathlib.Path(os.environ['PHONEMIZER_ESPEAK_LIBRARY'])
|
||||
if not (library.is_file() and os.access(library, os.R_OK)):
|
||||
raise RuntimeError( # pragma: nocover
|
||||
f'PHONEMIZER_ESPEAK_LIBRARY={library} '
|
||||
f'is not a readable file')
|
||||
return library.resolve()
|
||||
|
||||
library = (
|
||||
ctypes.util.find_library('espeak-ng') or
|
||||
ctypes.util.find_library('espeak'))
|
||||
if not library: # pragma: nocover
|
||||
raise RuntimeError(
|
||||
'failed to find espeak library')
|
||||
return library
|
||||
|
||||
def _fetch_version_and_path(self):
|
||||
"""Initializes version and dapa path from the espeak library"""
|
||||
version, data_path = self._espeak.info()
|
||||
|
||||
# pylint: disable=no-member
|
||||
self._data_path = pathlib.Path(data_path.decode())
|
||||
if not self._data_path.is_dir(): # pragma: nocover
|
||||
raise RuntimeError('failed to retrieve espeak data directory')
|
||||
|
||||
# espeak-1.48 appends the release date to version number, here we
|
||||
# simply ignore it
|
||||
version = version.decode().strip().split(' ')[0].replace('-dev', '')
|
||||
self._version = tuple(int(v) for v in version.split('.'))
|
||||
|
||||
@property
|
||||
def version(self) -> Tuple[int, int, int]:
|
||||
"""The espeak version as a tuple of integers (major, minor, patch)"""
|
||||
if self._version is None:
|
||||
self._fetch_version_and_path()
|
||||
return self._version
|
||||
|
||||
@property
|
||||
def library_path(self):
|
||||
"""The espeak library as a pathlib.Path instance"""
|
||||
return self._espeak.library_path
|
||||
|
||||
@property
|
||||
def data_path(self):
|
||||
"""The espeak data directory as a pathlib.Path instance"""
|
||||
if self._data_path is None:
|
||||
self._fetch_version_and_path()
|
||||
return self._data_path
|
||||
|
||||
@property
|
||||
def voice(self):
|
||||
"""The configured voice as an EspeakVoice instance
|
||||
|
||||
If `set_voice` has not been called, returns None
|
||||
|
||||
"""
|
||||
return self._voice
|
||||
|
||||
@functools.lru_cache(maxsize=None)
|
||||
def available_voices(self, name=None):
|
||||
"""Voices available for phonemization, as a list of `EspeakVoice`"""
|
||||
if name:
|
||||
name = EspeakVoice(language=name).to_ctypes()
|
||||
voices = self._espeak.list_voices(name or None)
|
||||
|
||||
index = 0
|
||||
available_voices = []
|
||||
# voices is an array to pointers, terminated by None
|
||||
while voices[index]:
|
||||
voice = voices[index].contents
|
||||
available_voices.append(EspeakVoice(
|
||||
name=os.fsdecode(voice.name).replace('_', ' '),
|
||||
language=os.fsdecode(voice.languages)[1:],
|
||||
identifier=os.fsdecode(voice.identifier)))
|
||||
index += 1
|
||||
return available_voices
|
||||
|
||||
def set_voice(self, voice_code):
|
||||
"""Setup the voice to use for phonemization
|
||||
|
||||
Parameters
|
||||
----------
|
||||
voice_code (str) : Must be a valid language code that is actually
|
||||
supported by espeak
|
||||
|
||||
Raises
|
||||
------
|
||||
RuntimeError if the required voice cannot be initialized
|
||||
|
||||
"""
|
||||
if 'mb' in voice_code:
|
||||
# this is an mbrola voice code. Select the voice by using
|
||||
# identifier in the format 'mb/{voice_code}'
|
||||
available = {
|
||||
voice.identifier[3:]: voice.identifier
|
||||
for voice in self.available_voices('mbrola')}
|
||||
else:
|
||||
# this are espeak voices. Select the voice using it's attached
|
||||
# language code. Consider only the first voice of a given code as
|
||||
# they are sorted by relevancy
|
||||
available = {}
|
||||
for voice in self.available_voices():
|
||||
if voice.language not in available:
|
||||
available[voice.language] = voice.identifier
|
||||
|
||||
try:
|
||||
voice_name = available[voice_code]
|
||||
except KeyError:
|
||||
raise RuntimeError(f'invalid voice code "{voice_code}"') from None
|
||||
|
||||
if self._espeak.set_voice_by_name(voice_name.encode('utf8')) != 0:
|
||||
raise RuntimeError( # pragma: nocover
|
||||
f'failed to load voice "{voice_code}"')
|
||||
|
||||
voice = self._get_voice()
|
||||
if not voice: # pragma: nocover
|
||||
raise RuntimeError(f'failed to load voice "{voice_code}"')
|
||||
self._voice = voice
|
||||
|
||||
def _get_voice(self):
|
||||
"""Returns the current voice used for phonemization
|
||||
|
||||
If no voice has been set up, returns None.
|
||||
|
||||
"""
|
||||
voice = self._espeak.get_current_voice()
|
||||
if voice.name:
|
||||
return EspeakVoice.from_ctypes(voice)
|
||||
return None # pragma: nocover
|
||||
|
||||
def text_to_phonemes(self, text: str, tie: bool = False) -> str:
|
||||
"""Translates a text into phonemes, must call set_voice() first.
|
||||
|
||||
This method is used by the Espeak backend. Wrapper on the
|
||||
espeak_TextToPhonemes function.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text (str) : the text to phonemize
|
||||
|
||||
tie (bool, optional) : When True use a '͡' character between
|
||||
consecutive characters of a single phoneme. Else separate phoneme
|
||||
with '_'. This option requires espeak>=1.49. Default to False.
|
||||
|
||||
Returns
|
||||
-------
|
||||
phonemes (str) : the phonemes for the text encoded in IPA, with '_' as
|
||||
phonemes separator (excepted if ``tie`` is True) and ' ' as word
|
||||
separator.
|
||||
|
||||
"""
|
||||
if self.voice is None: # pragma: nocover
|
||||
raise RuntimeError('no voice specified')
|
||||
|
||||
if tie and self.version <= (1, 48, 3):
|
||||
raise RuntimeError( # pragma: nocover
|
||||
'tie option only compatible with espeak>=1.49')
|
||||
|
||||
# from Python string to C void** (a pointer to a pointer to chars)
|
||||
text_ptr = ctypes.pointer(ctypes.c_char_p(text.encode('utf8')))
|
||||
|
||||
# input text is encoded as UTF8
|
||||
text_mode = 1
|
||||
|
||||
# output phonemes in IPA and separated by _, or with a tie character if
|
||||
# required. See comments for the function espeak_TextToPhonemes in
|
||||
# speak_lib.h of the espeak sources for details.
|
||||
if self.version <= (1, 48, 3): # pragma: nocover
|
||||
phonemes_mode = 0x03 | 0x01 << 4
|
||||
elif tie:
|
||||
phonemes_mode = 0x02 | 0x01 << 7 | ord('͡') << 8
|
||||
else:
|
||||
phonemes_mode = ord('_') << 8 | 0x02
|
||||
|
||||
result = []
|
||||
while text_ptr.contents.value is not None:
|
||||
phonemes = self._espeak.text_to_phonemes(
|
||||
text_ptr, text_mode, phonemes_mode)
|
||||
if phonemes:
|
||||
result.append(phonemes.decode())
|
||||
return ' '.join(result)
|
||||
|
||||
def synthetize(self, text: str):
|
||||
"""Translates a text into phonemes, must call set_voice() first.
|
||||
|
||||
Only compatible with espeak>=1.49. This method is used by the
|
||||
EspeakMbrola backend. Wrapper on the espeak_Synthesize function.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
text (str) : the text to phonemize
|
||||
|
||||
Returns
|
||||
-------
|
||||
phonemes (str) : the phonemes for the text encoded in SAMPA, with '_'
|
||||
as phonemes separator and no word separation.
|
||||
|
||||
"""
|
||||
|
||||
if self.version < (1, 49): # pragma: nocover
|
||||
raise RuntimeError('not compatible with espeak<=1.48')
|
||||
if self.voice is None: # pragma: nocover
|
||||
raise RuntimeError('no voice specified')
|
||||
|
||||
# init libc fopen and fclose functions
|
||||
self._libc.fopen.argtypes = [ctypes.c_char_p, ctypes.c_char_p]
|
||||
self._libc.fopen.restype = ctypes.c_void_p
|
||||
self._libc.fclose.argtypes = [ctypes.c_void_p]
|
||||
self._libc.fclose.restype = ctypes.c_int
|
||||
|
||||
# output phonemes in SAMPA and separated by _. Write the result to a
|
||||
# tempfile which is read back after phonemization (seems not possible
|
||||
# to redirect to stdout). See comments for the function
|
||||
# espeak_SetPhonemeTrace in speak_lib.h of the espeak sources for
|
||||
# details.
|
||||
self._tempfile.truncate(0)
|
||||
file_p = self._libc.fopen(
|
||||
self._tempfile.name.encode(),
|
||||
self._tempfile.mode.encode())
|
||||
|
||||
self._espeak.set_phoneme_trace(0x01 << 4 | ord('_') << 8, file_p)
|
||||
status = self._espeak.synthetize(
|
||||
ctypes.c_char_p(text.encode('utf8')),
|
||||
ctypes.c_size_t(len(text) + 1),
|
||||
ctypes.c_uint(0x01))
|
||||
self._libc.fclose(file_p) # because flush does not work...
|
||||
|
||||
if status != 0: # pragma: nocover
|
||||
raise RuntimeError('failed to synthetize')
|
||||
|
||||
self._tempfile.seek(0)
|
||||
phonemized = self._tempfile.read().decode().strip()
|
||||
return phonemized
|
||||
Reference in New Issue
Block a user