ru_tts-for-nvda
536 строк · 18.8 Кб
1# Copyright (C) 2021 - 2024 Александр Линьков <kvark128@yandex.ru>
2# This file is covered by the GNU General Public License.
3# See the file COPYING.txt for more details.
4
5import os.path6import threading7import queue8import re9import unicodedata10from collections import OrderedDict11from ctypes import *12
13import config14import addonHandler15import globalVars16import nvwave17from configobj import ConfigObj18from configobj.validate import Validator19from speech.commands import IndexCommand, PitchCommand, SpeechCommand20from synthDriverHandler import SynthDriver, VoiceInfo, synthIndexReached, synthDoneSpeaking21from autoSettingsUtils.driverSetting import NumericDriverSetting, BooleanDriverSetting22from logHandler import log23
24addonHandler.initTranslation()25
26MODULE_DIR = os.path.dirname(__file__)27RU_TTS_LIB_PATH = os.path.join(MODULE_DIR, "ru_tts.dll")28RULEX_LIB_PATH = os.path.join(MODULE_DIR, "rulex.dll")29RULEX_DB_PATH = os.path.join(MODULE_DIR, "rulex.db")30CONFIG_FILE_PATH = os.path.join(globalVars.appArgs.configPath, "ru_tts.ini")31CONFIG_SPEC_PATH = os.path.join(MODULE_DIR, "config.spec")32RU_TTS_CALLBACK = CFUNCTYPE(c_int, c_void_p, c_size_t, c_void_p)33BRAILLE_DOT_LABELS = ("первая", "вторая", "третья", "четвёртая", "пятая", "шестая", "седьмая", "восьмая")34
35SINGLE_CHARACTER_TRANSLATION_DICT = {36# Подавляем произношение круглых скобок, заменяя их на пробелы37ord('('): ' ',38ord(')'): ' ',39# Добавляем поддержку знака ударения40ord('\u0301'): '+',41}
42
43# Регулярные выражения для коррекции произношения
44RE_WORDS = re.compile("[а-яё\u0301]+", re.I)45RE_ABBREVIATIONS = re.compile(r"(?<![а-яёa-z])[bcdfghjklmnpqrstvwxzбвгджзклмнпрстфхцчшщ]{2,}(?![а-яёa-z])", re.I)46RE_LETTER_AFTER_NUMBER = re.compile(r"\d[а-яёa-z]", re.I)47RE_SINGLE_LATIN = re.compile(r"(?<![а-яёa-z])[a-z](?![а-яёa-z])", re.I)48RE_BRAILLE_PATTERNS = re.compile(r"[\u2800-\u28ff]")49
50# Диапазоны допустимых значений для скорости, высоты и интонации речи
51RATE_MIN = 2052RATE_MAX = 250 # движок поддерживает максимальное значение 500, но при значении более 250 наблюдается искажение звука53PITCH_MIN = 5054PITCH_MAX = 30055INTONATION_MIN = 056INTONATION_MAX = 14057
58@POINTER
59class TTS(Structure): pass60
61@POINTER
62class RULEXDB(Structure): pass63
64# Ограничения на размер некоторых значений при работе с базой данных rulex
65RULEXDB_MAX_KEY_SIZE = 5066RULEXDB_MAX_RECORD_SIZE = 20067RULEXDB_BUFSIZE = 25668
69# Режимы доступа к базе данных rulex
70RULEXDB_SEARCH = 071RULEXDB_UPDATE = 172RULEXDB_CREATE = 273
74# Коды возврата при работе с базой данных rulex
75RULEXDB_SUCCESS = 076RULEXDB_SPECIAL = 177RULEXDB_FAILURE = -178RULEXDB_EMALLOC = -279RULEXDB_EINVKEY = -380RULEXDB_EINVREC = -481RULEXDB_EPARM = -582RULEXDB_EACCESS = -683
84# Управляющие флаги для поля flags в структуре RU_TTS_CONF_T
85DEC_SEP_POINT = 1 # Использовать точку в качестве десятичного разделителя86DEC_SEP_COMMA = 2 # Использовать запятую в качестве десятичного разделителя87USE_ALTERNATIVE_VOICE = 4 # Использовать женский голос88
89class RU_TTS_CONF_T(Structure):90_fields_ = [91("speech_rate", c_int),92("voice_pitch", c_int),93("intonation", c_int),94("general_gap_factor", c_int),95("comma_gap_factor", c_int),96("dot_gap_factor", c_int),97("semicolon_gap_factor", c_int),98("colon_gap_factor", c_int),99("question_gap_factor", c_int),100("exclamation_gap_factor", c_int),101("intonational_gap_factor", c_int),102("flags", c_int),103]104
105# Параметры синтезатора настраиваемые через графический интерфейс
106SPEECH_RATE_PARAM = "speech_rate"107VOICE_PITCH_PARAM = "voice_pitch"108INTONATION_PARAM = "intonation"109GENERAL_GAP_FACTOR_PARAM = "general_gap_factor"110FLAGS_PARAM = "flags"111
112# Возвращаемые значения для функции обратного вызова, обрабатывающей аудиоданные
113CALLBACK_CONTINUE_SYNTHESIS = 0114CALLBACK_ABORT_SYNTHESIS = 1115
116class AudioCallback(object):117
118def __init__(self, silence_flag, player):119self.__silence_flag = silence_flag120self.__player = player121
122def __call__(self, buffer, size, user_data):123if self.__silence_flag.is_set():124return CALLBACK_ABORT_SYNTHESIS125try:126if size > 0:127data = string_at(buffer, size*sizeof(c_short))128self.__player.feed(data)129if self.__silence_flag.is_set():130return CALLBACK_ABORT_SYNTHESIS131return CALLBACK_CONTINUE_SYNTHESIS132except Exception:133log.error("ru_tts AudioCallback", exc_info=True)134return CALLBACK_ABORT_SYNTHESIS135
136class RulexDict(object):137
138def __init__(self, db_path):139# Загрузка rulex.dll. Драйвера базы данных для словаря произношений140self.__rulexdb = CDLL(RULEX_LIB_PATH)141self.__rulexdb.rulexdb_open.argtypes = (c_char_p, c_int)142self.__rulexdb.rulexdb_open.restype = RULEXDB143self.__rulexdb.rulexdb_search.argtypes = (RULEXDB, c_char_p, c_char_p, c_int)144self.__rulexdb.rulexdb_search.restype = c_int145self.__rulexdb.rulexdb_close.argtypes = (RULEXDB,)146
147# Открытие базы данных со словарём произношений и создание буфера в который мы будем получать результаты поиска по этой базе148# При открытии базы данных драйверу передаётся указатель на строку с путём к файлу. Эта строка используется позже, поэтому нам необходимо защитить ее от сборки мусора149self.__searchBuf = create_string_buffer(RULEXDB_BUFSIZE)150self.__db_path = db_path.encode("mbcs")151self.__db = self.__rulexdb.rulexdb_open(self.__db_path, RULEXDB_SEARCH)152if not self.__db:153raise RuntimeError("rulex: failed to open the dictionary database")154
155def search(self, match):156word = match.group()157key = word.lower().encode("koi8-r", "replace")158if len(key) <= RULEXDB_MAX_KEY_SIZE:159if self.__rulexdb.rulexdb_search(self.__db, key, self.__searchBuf, 0) == RULEXDB_SUCCESS:160return self.__searchBuf.value.decode("koi8-r")161return word162
163def close(self):164if self.__db:165self.__rulexdb.rulexdb_close(self.__db)166try:167windll.kernel32.FreeLibrary(self.__rulexdb._handle)168except Exception:169log.error("rulex: can not unload dll")170finally:171self.__rulexdb = None172
173class SpeakText(object):174
175def __init__(self, text, lib, tts, tts_config, silence_flag, index, onIndexReached):176self.__text = text177self.__lib = lib178self.__tts = tts179self.__config = tts_config180self.__silence_flag = silence_flag181self.__index = index182self.__onIndexReached = onIndexReached183
184def __call__(self):185if self.__silence_flag.is_set():186return187text = self.__text.encode("koi8-r", "replace")188if text:189self.__lib.tts_speak(self.__tts, byref(self.__config), text)190if self.__index is None or self.__silence_flag.is_set():191return192self.__onIndexReached(self.__index)193
194class DoneSpeaking(object):195
196def __init__(self, player, onIndexReached):197self.__player = player198self.__onIndexReached = onIndexReached199
200def __call__(self):201self.__player.idle()202self.__onIndexReached(None)203
204class SetParameter(object):205
206def __init__(self, conf, param, value):207self.__config = conf208self.param = param209self.value = value210
211def __call__(self):212setattr(self.__config, self.param, self.value)213
214class TaskThread(threading.Thread):215
216def __init__(self, task_queue):217super().__init__()218self.__queue = task_queue219self.daemon = True220
221def run(self):222while True:223try:224task = self.__queue.get()225if task is None:226break227task()228except Exception:229log.error("ru_tts: error while processing a task", exc_info=True)230
231class SynthDriver(SynthDriver):232name = "ru_tts"233description = "ru_tts"234
235supportedSettings = [236SynthDriver.VoiceSetting(),237SynthDriver.RateSetting(),238SynthDriver.RateBoostSetting(),239SynthDriver.PitchSetting(),240SynthDriver.VolumeSetting(),241SynthDriver.InflectionSetting(),242NumericDriverSetting("gapFactor", _("Pause between phrases"), availableInSettingsRing=True),243]244
245supportedCommands = {IndexCommand, PitchCommand}246supportedNotifications = {synthIndexReached, synthDoneSpeaking}247
248def __init__(self):249# Первым делом загружаем основной движок синтезатора250self.__ru_tts_lib = CDLL(RU_TTS_LIB_PATH)251self.__ru_tts_lib.tts_create.argtypes = (RU_TTS_CALLBACK,)252self.__ru_tts_lib.tts_create.restype = TTS253self.__ru_tts_lib.tts_destroy.argtypes = (TTS,)254self.__ru_tts_lib.tts_speak.argtypes = (TTS, POINTER(RU_TTS_CONF_T), c_char_p)255self.__ru_tts_lib.tts_setVolume.argtypes = (TTS, c_float)256self.__ru_tts_lib.tts_setSpeed.argtypes = (TTS, c_float)257self.__ru_tts_lib.ru_tts_config_init.argtypes = (POINTER(RU_TTS_CONF_T),)258
259self.__config = RU_TTS_CONF_T()260self.__ru_tts_lib.ru_tts_config_init(byref(self.__config))261self.__user_config = self._getUserConfiguration()262
263params = self.__user_config["Parameters"]264self.__config.comma_gap_factor = params["comma_gap_factor"]265self.__config.dot_gap_factor = params["dot_gap_factor"]266self.__config.semicolon_gap_factor = params["semicolon_gap_factor"]267self.__config.colon_gap_factor = params["colon_gap_factor"]268self.__config.question_gap_factor = params["question_gap_factor"]269self.__config.exclamation_gap_factor = params["exclamation_gap_factor"]270self.__config.intonational_gap_factor = params["intonational_gap_factor"]271self.__config.flags = 0272
273if params["dec_sep_point"]:274self.__config.flags |= DEC_SEP_POINT275
276if params["dec_sep_comma"]:277self.__config.flags |= DEC_SEP_COMMA278
279self.__normalizationForm = None280if params["use_unicode_normalization"]:281validForms = ("NFC", "NFKC", "NFD", "NFKD")282form = params["unicode_normalization_form"]283if form in validForms:284self.__normalizationForm = form285
286try:287self.__rulex_dict = RulexDict(RULEX_DB_PATH)288except Exception:289self.__rulex_dict = None290log.warning("rulex not available", exc_info=True)291else:292self.__rulexSetting = BooleanDriverSetting("useRulex", _("Use RuLex pronunciation dictionary"), availableInSettingsRing=True)293self.supportedSettings.append(self.__rulexSetting)294
295self.__silence_flag = threading.Event()296self.__player = nvwave.WavePlayer(channels=1, samplesPerSec=params["samples_per_sec"], bitsPerSample=16, outputDevice=config.conf["speech"]["outputDevice"])297self.__audio_callback = AudioCallback(self.__silence_flag, self.__player)298
299self.__c_audio_callback = RU_TTS_CALLBACK(self.__audio_callback)300self.__tts = self.__ru_tts_lib.tts_create(self.__c_audio_callback)301if not self.__tts:302raise RuntimeError("ru_tts: failed to create a TTS instance")303
304self.__speechFlags = self.__config.flags305self.__rate = self._paramToPercent(self.__config.speech_rate, RATE_MIN, RATE_MAX)306self.__rateBoost = False307self.__pitch = self._paramToPercent(self.__config.voice_pitch, PITCH_MIN, PITCH_MAX)308self.__volume = 50309self.__ru_tts_lib.tts_setVolume(self.__tts, self.__volume/100)310self.__inflection = self._paramToPercent(self.__config.intonation, INTONATION_MIN, INTONATION_MAX)311self.__gap_factor_max = self._maxGapRange(self.__config.speech_rate)312self.__gapFactor = self._paramToPercent(self.__config.general_gap_factor, 0, self.__gap_factor_max)313self.__useRulex = False314
315self.__task_queue = queue.Queue()316self.__task_thread = TaskThread(self.__task_queue)317self.__task_thread.start()318
319@classmethod320def check(cls):321return True322
323def terminate(self):324self.cancel()325self.__task_queue.put(None)326self.__task_thread.join()327self.__player.close()328if self.__rulex_dict is not None:329self.supportedSettings.remove(self.__rulexSetting)330self.__rulex_dict.close()331self.__rulex_dict = None332self.__config = None333self.__ru_tts_lib.tts_destroy(self.__tts)334self.__tts = None335# Предотвращаем образование циклических ссылок336self.__audio_callback = None337self.__c_audio_callback = None338# Пробуем выгрузить основной движок синтезатора339try:340windll.kernel32.FreeLibrary(self.__ru_tts_lib._handle)341except Exception:342log.error("ru_tts: can not unload dll")343finally:344self.__ru_tts_lib = None345
346def _getUserConfiguration(self):347with open(CONFIG_SPEC_PATH, encoding="utf-8") as spec:348conf = ConfigObj(infile=CONFIG_FILE_PATH, configspec=spec, encoding="utf-8", default_encoding="utf-8")349val = Validator()350conf.validate(val, copy=True)351if not globalVars.appArgs.secure:352try:353conf.write()354except OSError:355log.error("ru_tts: failed to write config file", exc_info=True)356return conf357
358def _setParameter(self, param, value):359task = SetParameter(self.__config, param, value)360self.__task_queue.put(task)361
362def speak(self, speechSequence):363textList = []364pitchChanged = False365for item in speechSequence:366if isinstance(item, str):367textList.append(item)368elif isinstance(item, IndexCommand):369self.do_speak(textList, item.index)370textList = []371elif isinstance(item, PitchCommand):372self.do_speak(textList)373textList = []374pitch = self._percentToParam(item.newValue, PITCH_MIN, PITCH_MAX)375self._setParameter(VOICE_PITCH_PARAM, pitch)376pitchChanged = True377elif isinstance(item, SpeechCommand):378log.debugWarning(f"Unsupported speech command: {item}")379else:380log.error(f"Unknown speech: {item}")381self.do_speak(textList)382if pitchChanged:383pitch = self._percentToParam(self.__pitch, PITCH_MIN, PITCH_MAX)384self._setParameter(VOICE_PITCH_PARAM, pitch)385self.__task_queue.put(DoneSpeaking(self.__player, self._onIndexReached))386
387def do_speak(self, textList, index=None):388text = "".join(textList).strip()389if self.__normalizationForm is not None:390text = unicodedata.normalize(self.__normalizationForm, text)391if len(text) == 1:392text = self.__user_config["SingleCharacters"].get(text.lower(), text)393else:394text = RE_SINGLE_LATIN.sub(self._singleLatinSearch, text)395text = RE_ABBREVIATIONS.sub(self._abbreviationSearch, text)396text = RE_LETTER_AFTER_NUMBER.sub(self._letterAfterNumberSearch, text)397text = "".join([self.__user_config["Characters"].get(ch.lower(), ch) for ch in text])398if self.__useRulex and (self.__rulex_dict is not None):399text = RE_WORDS.sub(self.__rulex_dict.search, text)400text = text.translate(SINGLE_CHARACTER_TRANSLATION_DICT)401text = RE_BRAILLE_PATTERNS.sub(self._brailleDotsSearch, text)402task = SpeakText(text, self.__ru_tts_lib, self.__tts, self.__config, self.__silence_flag, index, self._onIndexReached)403self.__task_queue.put(task)404
405def pause(self, switch):406self.__player.pause(switch)407
408def cancel(self):409tasks = []410try:411while True:412task = self.__task_queue.get_nowait()413if not isinstance(task, SpeakText):414tasks.append(task)415except queue.Empty:416pass417for task in tasks:418self.__task_queue.put(task)419self.__silence_flag.set()420self.__task_queue.put(self.__silence_flag.clear)421self.__player.stop()422
423def _singleLatinSearch(self, match):424ch = match.group().lower()425return self.__user_config["SingleCharacters"].get(ch, ch)426
427def _abbreviationSearch(self, match):428word = match.group().lower()429return " ".join([self.__user_config["SingleCharacters"].get(ch, ch) for ch in word])430
431def _letterAfterNumberSearch(self, match):432return " ".join(match.group())433
434def _brailleDotsSearch(self, match):435ch = match.group()436dotLabels = []437for offset, label in enumerate(BRAILLE_DOT_LABELS):438if ord(ch) >> offset & 1:439dotLabels.append(label)440if len(dotLabels) == 0:441return " брайлевский пробел "442elif len(dotLabels) == 8:443return " брайлевское восьмиточие "444else:445dotLabels.append("брайлевские точки" if len(dotLabels) > 1 else "брайлевская точка")446return f" {' '.join(dotLabels)} "447
448def _onIndexReached(self, index):449if index is not None:450synthIndexReached.notify(synth=self, index=index)451else:452synthDoneSpeaking.notify(synth=self)453
454def _maxGapRange(self, rate):455return 125 * rate // RATE_MIN456
457def _get_language(self):458return "ru"459
460def _get_rate(self):461return self.__rate462
463def _set_rate(self, value):464self.__rate = value465rate = self._percentToParam(self.__rate, RATE_MIN, RATE_MAX)466self._setParameter(SPEECH_RATE_PARAM, rate)467# Коэффициент паузы зависит от скорости речи. Необходимо вычислить его заново468self.__gap_factor_max = self._maxGapRange(rate)469gap_factor = self._percentToParam(self.__gapFactor, 0, self.__gap_factor_max)470self._setParameter(GENERAL_GAP_FACTOR_PARAM, gap_factor)471
472def _get_pitch(self):473return self.__pitch474
475def _set_pitch(self, value):476self.__pitch = value477pitch = self._percentToParam(self.__pitch, PITCH_MIN, PITCH_MAX)478self._setParameter(VOICE_PITCH_PARAM, pitch)479
480def _get_volume(self):481return self.__volume482
483def _set_volume(self, volume):484self.__volume = volume485task = lambda: self.__ru_tts_lib.tts_setVolume(self.__tts, volume/100)486self.__task_queue.put(task)487
488def _getAvailableVoices(self):489voices = OrderedDict()490for id, displayName in enumerate((_("Male"), _("Female"))):491id = str(id)492voices[id] = VoiceInfo(id, displayName, "ru")493return voices494
495def _get_voice(self):496return str((self.__speechFlags & USE_ALTERNATIVE_VOICE) >> 2)497
498def _set_voice(self, voice):499if voice in self.availableVoices:500if (int(voice) << 2) == USE_ALTERNATIVE_VOICE:501self.__speechFlags |= USE_ALTERNATIVE_VOICE502else:503self.__speechFlags &= ~USE_ALTERNATIVE_VOICE504self._setParameter(FLAGS_PARAM, self.__speechFlags)505
506def _get_rateBoost(self):507return self.__rateBoost508
509def _set_rateBoost(self, enable):510if enable != self.__rateBoost:511self.__rateBoost = enable512speed = 2.0 if self.__rateBoost else 1.0513task = lambda: self.__ru_tts_lib.tts_setSpeed(self.__tts, speed)514self.__task_queue.put(task)515
516def _get_gapFactor(self):517return self.__gapFactor518
519def _set_gapFactor(self, value):520self.__gapFactor = value521gap_factor = self._percentToParam(self.__gapFactor, 0, self.__gap_factor_max)522self._setParameter(GENERAL_GAP_FACTOR_PARAM, gap_factor)523
524def _get_inflection(self):525return self.__inflection526
527def _set_inflection(self, value):528self.__inflection = value529intonation = self._percentToParam(self.__inflection, INTONATION_MIN, INTONATION_MAX)530self._setParameter(INTONATION_PARAM, intonation)531
532def _get_useRulex(self):533return self.__useRulex534
535def _set_useRulex(self, value):536self.__useRulex = value537