├── .gitignore ├── .travis.yml ├── LICENSE.txt ├── README.md ├── __init__.py ├── awesometts ├── LICENSE.txt ├── __init__.py ├── blank.mp3 ├── bundle.py ├── config.py ├── conversion.py ├── gui │ ├── __init__.py │ ├── base.py │ ├── common.py │ ├── configurator.py │ ├── generator.py │ ├── groups.py │ ├── icons │ │ ├── arrow-down.png │ │ ├── arrow-up.png │ │ ├── clock16.png │ │ ├── configure.png │ │ ├── document-new.png │ │ ├── editclear.png │ │ ├── editdelete.png │ │ ├── emblem-favorite.png │ │ ├── fileclose.png │ │ ├── find.png │ │ ├── kpersonalizer.png │ │ ├── list-add.png │ │ ├── player-time.png │ │ ├── rating.png │ │ └── speaker.png │ ├── listviews.py │ ├── presets.py │ ├── reviewer.py │ ├── stripper.py │ ├── templater.py │ └── updater.py ├── paths.py ├── player.py ├── router.py ├── service │ ├── __init__.py │ ├── abair.py │ ├── azuretts.py │ ├── baidu.py │ ├── base.py │ ├── collins.py │ ├── common.py │ ├── duden.py │ ├── ekho.py │ ├── espeak.py │ ├── festival.py │ ├── fluencynl.py │ ├── google.py │ ├── googletts.py │ ├── howjsay.py │ ├── imtranslator.py │ ├── ispeech.py │ ├── naver.py │ ├── neospeech.py │ ├── oddcast.py │ ├── oxford.py │ ├── pico2wave.py │ ├── rhvoice.py │ ├── sapi5com.py │ ├── sapi5js.js │ ├── sapi5js.py │ ├── say.py │ ├── spanishdict.py │ ├── voicetext.py │ ├── wiktionary.py │ ├── yandex.py │ └── youdao.py ├── text.py └── updates.py ├── tests ├── __init__.py └── test_run.py ├── tools ├── install.sh ├── package.sh ├── symlink.sh └── test.sh └── user_files └── README.txt /.gitignore: -------------------------------------------------------------------------------- 1 | .[eE][nN][vV]*/ 2 | .[vV][eE][nN][vV]*/ 3 | .[vV][iI][rR][tT][uU][aA][lL][eE][nN][vV]*/ 4 | [eE][nN][vV]*/ 5 | [vV][eE][nN][vV]*/ 6 | [vV][iI][rR][tT][uU][aA][lL][eE][nN][vV]*/ 7 | __pycache__/ 8 | *.db 9 | *.log 10 | *.py[cod] 11 | 12 | /awesometts/.cache/ 13 | anki_root 14 | anki_testing 15 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | sudo: required 2 | dist: xenial 3 | language: python 4 | 5 | python: 6 | - 3.6 7 | - 3.7 8 | 9 | install: 10 | - git clone https://github.com/krassowski/anki_testing 11 | - source anki_testing/setup.sh 12 | 13 | script: 14 | - bash tools/test.sh 15 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # AwesomeTTS Anki add-on 2 | [![Build Status](https://travis-ci.org/AwesomeTTS/awesometts-anki-addon.svg?branch=master)](https://travis-ci.org/AwesomeTTS/awesometts-anki-addon) 3 | 4 | AwesomeTTS makes it easy for language-learners and other students to add 5 | speech to their personal [Anki](https://apps.ankiweb.net) card decks. 6 | 7 | Once loaded into the Anki `addons` directory, the AwesomeTTS add-on code 8 | enables both on-demand playback and recording functionality. 9 | 10 | ## License 11 | 12 | AwesomeTTS is free and open-source software. The add-on code that runs within 13 | Anki is released under the [GNU GPL v3](LICENSE.txt). 14 | -------------------------------------------------------------------------------- /__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Entry point for AwesomeTTS add-on from Anki 21 | 22 | Performs any migration tasks and then loads the 'awesometts' package. 23 | """ 24 | 25 | from sys import stderr 26 | 27 | __all__ = [] 28 | 29 | 30 | if __name__ == "__main__": 31 | stderr.write( 32 | "AwesomeTTS is a text-to-speech add-on for Anki.\n" 33 | "It is not intended to be run directly.\n" 34 | "To learn more or download Anki, visit .\n" 35 | ) 36 | exit(1) 37 | 38 | 39 | # n.b. Import is intentionally placed down here so that Python processes it 40 | # only if the module check above is not tripped. 41 | 42 | from . import awesometts # noqa, pylint:disable=wrong-import-position 43 | 44 | 45 | # If a specific component of AwesomeTTS that you do not need is causing a 46 | # problem (e.g. conflicting with another add-on), you can disable it here by 47 | # prefixing it with a hash (#) sign and restarting Anki. 48 | 49 | awesometts.browser_menus() # mass generator and MP3 stripper 50 | awesometts.cache_control() # automatically clear the media cache regularly 51 | awesometts.cards_button() # on-the-fly templater helper in card view 52 | awesometts.config_menu() # provides access to configuration dialog 53 | awesometts.editor_button() # single audio clip generator button 54 | awesometts.reviewer_hooks() # on-the-fly playback/shortcuts, context menus 55 | awesometts.sound_tag_delays() # delayed playing of stored [sound]s in review 56 | awesometts.temp_files() # remove temporary files upon session exit 57 | awesometts.update_checker() # if enabled, runs the add-on update checker 58 | awesometts.window_shortcuts() # enable/update shortcuts for add-on windows 59 | -------------------------------------------------------------------------------- /awesometts/LICENSE.txt: -------------------------------------------------------------------------------- 1 | ../LICENSE.txt -------------------------------------------------------------------------------- /awesometts/blank.mp3: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/blank.mp3 -------------------------------------------------------------------------------- /awesometts/bundle.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Bundling 21 | """ 22 | 23 | __all__ = ['Bundle'] 24 | 25 | 26 | class Bundle(object): # exposes attributes, not methods, pylint:disable=R0903 27 | """ 28 | Exposes a class that can be used for bundling some objects together. 29 | This can be used as an alternative to a dict, and will have a syntax 30 | that is cleaner and shorter. 31 | 32 | Example 33 | 34 | >>> from bundle import Bundle 35 | >>> things = Bundle(a=1, b=2, c=3) 36 | >>> things.a 37 | 1 38 | >>> things.b 39 | 2 40 | >>> things.c 41 | 3 42 | """ 43 | 44 | def __init__(self, **kwargs): 45 | """ 46 | Make each of the named keyword arguments available as an 47 | attribute on the instance. 48 | """ 49 | 50 | for key, value in kwargs.items(): 51 | setattr(self, key, value) 52 | -------------------------------------------------------------------------------- /awesometts/conversion.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Helpful type conversions 21 | """ 22 | 23 | import json 24 | import re 25 | 26 | from PyQt5.QtCore import Qt 27 | 28 | __all__ = ['compact_json', 'deserialized_dict', 'lax_bool', 29 | 'normalized_ascii', 'nullable_key', 'nullable_int', 30 | 'substitution_compiled', 'substitution_json', 'substitution_list'] 31 | 32 | 33 | def compact_json(obj): 34 | """Given an object, return a minimal JSON-encoded string.""" 35 | 36 | return json.dumps(obj, separators=compact_json.SEPARATORS) 37 | 38 | compact_json.SEPARATORS = (',', ':') 39 | 40 | 41 | def deserialized_dict(json_str): 42 | """ 43 | Given a JSON string, returns a dict. If the input is invalid or 44 | does not have an object at the top-level, returns an empty dict. 45 | """ 46 | 47 | if isinstance(json_str, dict): 48 | return json_str 49 | 50 | try: 51 | obj = json.loads(json_str) 52 | except Exception: 53 | return {} 54 | 55 | return obj if isinstance(obj, dict) else {} 56 | 57 | 58 | def lax_bool(value): 59 | """ 60 | Like bool(), but correctly returns False for '0', 'false', and 61 | similar strings. 62 | """ 63 | 64 | if isinstance(value, str): 65 | value = value.strip().strip('-0').lower() 66 | return value not in lax_bool.FALSE_STRINGS 67 | 68 | return bool(value) 69 | 70 | lax_bool.FALSE_STRINGS = ['', 'false', 'no', 'off', 'unset'] 71 | 72 | 73 | def normalized_ascii(value): 74 | """ 75 | Returns a plain ASCII string containing only lowercase 76 | alphanumeric characters from the given value. 77 | """ 78 | value = value.encode('ascii', 'ignore').decode() 79 | 80 | # TODO: .isalnum() could be used here, but it is not equivalent 81 | return ''.join(char.lower() 82 | for char in value 83 | if char.isalpha() or char.isdigit()) 84 | 85 | 86 | def nullable_key(value): 87 | """ 88 | Returns an instance of PyQt5.QtCore.Qt.Key for the given value, if 89 | possible. If the incoming value cannot be represented as a key, 90 | returns None. 91 | """ 92 | 93 | if isinstance(value, Qt.Key): 94 | return value 95 | 96 | value = nullable_int(value) 97 | return Qt.Key(value) if value else None 98 | 99 | 100 | def nullable_int(value): 101 | """ 102 | Returns an integer for the given value, if possible. If the incoming 103 | value cannot be represented as an integer, returns None. 104 | """ 105 | 106 | try: 107 | return int(value) 108 | except Exception: 109 | return None 110 | 111 | 112 | def substitution_compiled(rule): 113 | """ 114 | Given a substitution rule, returns a compiled matcher object using 115 | re.compile(). Because advanced substitutions execute after all 116 | whitespace is collapsed, neither re.DOTALL nor re.MULTILINE need to 117 | be supported here. 118 | """ 119 | 120 | assert rule['input'], "Input pattern may not be empty" 121 | return re.compile( 122 | pattern=rule['input'] if rule['regex'] else re.escape(rule['input']), 123 | flags=sum( 124 | value 125 | for key, value in substitution_compiled.FLAGS 126 | if rule[key] 127 | ), 128 | ) 129 | 130 | substitution_compiled.FLAGS = [('ignore_case', re.IGNORECASE), 131 | ('unicode', re.UNICODE)] 132 | 133 | 134 | def substitution_json(rules): 135 | """ 136 | Given a list of substitution rules, filters out the compiled member 137 | from each rule and returns the list serialized as JSON. 138 | """ 139 | 140 | return ( 141 | compact_json([ 142 | { 143 | key: value 144 | for key, value 145 | in item.items() 146 | if key != 'compiled' 147 | } 148 | for item in rules 149 | ]) 150 | if rules and isinstance(rules, list) 151 | else '[]' 152 | ) 153 | 154 | 155 | def substitution_list(json_str): 156 | """ 157 | Given a JSON string, returns a list of valid substitution rules with 158 | each rule's 'compiled' member instantiated. 159 | """ 160 | 161 | try: 162 | candidates = json.loads(json_str) 163 | if not isinstance(candidates, list): 164 | raise ValueError 165 | 166 | except Exception: 167 | return [] 168 | 169 | rules = [] 170 | 171 | for candidate in candidates: 172 | if not ('replace' in candidate and 173 | isinstance(candidate['replace'], str)): 174 | continue 175 | 176 | for key, default in substitution_list.DEFAULTS: 177 | if key not in candidate: 178 | candidate[key] = default 179 | 180 | try: 181 | candidate['compiled'] = substitution_compiled(candidate) 182 | except Exception: # sre_constants.error, pylint:disable=broad-except 183 | continue 184 | 185 | rules.append(candidate) 186 | 187 | return rules 188 | 189 | substitution_list.DEFAULTS = [('regex', False), ('ignore_case', True), 190 | ('unicode', True)] 191 | -------------------------------------------------------------------------------- /awesometts/gui/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | GUI classes for AwesomeTTS 21 | """ 22 | 23 | from .common import ( 24 | Action, 25 | Button, 26 | HTMLButton, 27 | Filter, 28 | ICON, 29 | ) 30 | 31 | from .configurator import Configurator 32 | 33 | from .generator import ( 34 | BrowserGenerator, 35 | EditorGenerator, 36 | ) 37 | 38 | from .stripper import BrowserStripper 39 | 40 | from .templater import Templater 41 | 42 | from .updater import Updater 43 | 44 | from .reviewer import Reviewer 45 | 46 | __all__ = [ 47 | # common 48 | 'Action', 49 | 'Button', 50 | 'HTMLButton', 51 | 'Filter', 52 | 'ICON', 53 | 54 | # dialog windows 55 | 'Configurator', 56 | 'BrowserGenerator', 57 | 'EditorGenerator', 58 | 'BrowserStripper', 59 | 'Templater', 60 | 'Updater', 61 | 62 | # headless 63 | 'Reviewer', 64 | ] 65 | -------------------------------------------------------------------------------- /awesometts/gui/groups.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """Groups management dialog""" 20 | 21 | from PyQt5 import QtCore, QtWidgets, QtGui 22 | 23 | from ..paths import ICONS 24 | from .base import Dialog 25 | from .common import Label, Note, Slate 26 | from .listviews import GroupListView 27 | 28 | __all__ = ['Groups'] 29 | 30 | 31 | class Groups(Dialog): 32 | """Provides a dialog for editing groups of presets.""" 33 | 34 | __slots__ = [ 35 | '_ask', # dialog interface for asking for user input 36 | '_current_group', # current group name 37 | '_groups', # deep copy from config['groups'] 38 | ] 39 | 40 | def __init__(self, ask, *args, **kwargs): 41 | super(Groups, self).__init__(title="Manage Preset Groups", 42 | *args, **kwargs) 43 | self._ask = ask 44 | self._current_group = None 45 | self._groups = None # set in show() 46 | 47 | # UI Construction ######################################################## 48 | 49 | def _ui(self): 50 | """ 51 | Returns a vertical layout with a banner and controls to update 52 | the groups. 53 | """ 54 | 55 | layout = super(Groups, self)._ui() 56 | 57 | groups = QtWidgets.QComboBox() 58 | groups.setObjectName('groups') 59 | groups.setSizePolicy(QtWidgets.QSizePolicy.MinimumExpanding, 60 | QtWidgets.QSizePolicy.Preferred) 61 | groups.activated.connect(self._on_group_activated) 62 | 63 | # TODO: icons do not work with 2.1 64 | delete = QtWidgets.QPushButton(QtGui.QIcon(f'{ICONS}/editdelete.png'), "") 65 | delete.setObjectName('delete') 66 | delete.setIconSize(QtCore.QSize(16, 16)) 67 | delete.setFixedSize(18, 18) 68 | delete.setFlat(True) 69 | delete.clicked.connect(self._on_group_delete) 70 | 71 | add = QtWidgets.QPushButton(QtGui.QIcon(f'{ICONS}/list-add.png'), "") 72 | add.setObjectName('add') 73 | add.setIconSize(QtCore.QSize(16, 16)) 74 | add.setFixedSize(18, 18) 75 | add.setFlat(True) 76 | add.clicked.connect(self._on_group_add) 77 | 78 | hor = QtWidgets.QHBoxLayout() 79 | hor.addWidget(groups) 80 | hor.addWidget(delete) 81 | hor.addWidget(add) 82 | hor.addStretch() 83 | 84 | vert = QtWidgets.QVBoxLayout() 85 | vert.setObjectName('child') 86 | 87 | layout.addLayout(hor) 88 | layout.addSpacing(self._SPACING) 89 | layout.addLayout(vert) 90 | layout.addWidget(self._ui_buttons()) 91 | return layout 92 | 93 | # Events ################################################################# 94 | 95 | def show(self, *args, **kwargs): 96 | """Restores state on opening the dialog.""" 97 | 98 | self._groups = { 99 | name: {'mode': group['mode'], 'presets': group['presets'][:]} 100 | for name, group in self._addon.config['groups'].items() 101 | } 102 | self._on_refresh() 103 | 104 | super(Groups, self).show(*args, **kwargs) 105 | 106 | def help_request(self): 107 | """Launch browser to the documentation for groups.""" 108 | 109 | self._launch_link('usage/groups') 110 | 111 | def _on_group_activated(self, idx): 112 | """Show the correct panel for the selected group.""" 113 | 114 | self._pull_presets() 115 | delete = self.findChild(QtWidgets.QPushButton, 'delete') 116 | vert = self.findChild(QtWidgets.QLayout, 'child') 117 | 118 | while vert.count(): 119 | vert.itemAt(0).widget().setParent(None) 120 | 121 | if idx > 0: 122 | delete.setEnabled(True) 123 | 124 | name = self.findChild(QtWidgets.QComboBox, 'groups').currentText() 125 | self._current_group = name 126 | group = self._groups[name] 127 | 128 | randomize = QtWidgets.QRadioButton("randomized") 129 | randomize.setChecked(group['mode'] == 'random') 130 | randomize.clicked.connect(lambda: group.update({'mode': 'random'})) 131 | 132 | in_order = QtWidgets.QRadioButton("in-order") 133 | in_order.setChecked(group['mode'] == 'ordered') 134 | in_order.clicked.connect(lambda: group.update({'mode': 'ordered'})) 135 | 136 | hor = QtWidgets.QHBoxLayout() 137 | hor.addWidget(Label("Mode:")) 138 | hor.addWidget(randomize) 139 | hor.addWidget(in_order) 140 | hor.addStretch() 141 | 142 | inner = QtWidgets.QVBoxLayout() 143 | inner.addLayout(hor) 144 | inner.addLayout(Slate( 145 | "Preset", 146 | GroupListView, 147 | [sorted( 148 | self._addon.config['presets'].keys(), 149 | key=lambda preset: preset.lower(), 150 | )], 151 | 'presets', 152 | )) 153 | 154 | slate = QtWidgets.QWidget() 155 | slate.setLayout(inner) 156 | 157 | vert.addWidget(slate) 158 | 159 | self.findChild(QtWidgets.QListView, 160 | 'presets').setModel(group['presets']) 161 | 162 | else: 163 | delete.setEnabled(False) 164 | 165 | self._current_group = None 166 | 167 | header = Label("About Preset Groups") 168 | header.setFont(self._FONT_HEADER) 169 | 170 | vert.addWidget(header) 171 | vert.addWidget(Note("Preset groups can operate in two modes: " 172 | "randomized or in-order.")) 173 | vert.addWidget(Note("The randomized mode can be helpful if you " 174 | "want to hear playback in a variety of preset " 175 | "voices while you study.")) 176 | vert.addWidget(Note("The in-order mode can be used if you prefer " 177 | "playback from a particular preset, but want " 178 | "to fallback to another preset if your first " 179 | "choice does not have audio for your input " 180 | "phrase.")) 181 | vert.addWidget(Label(""), 1) 182 | 183 | def _on_group_delete(self): 184 | """Delete the selected group.""" 185 | 186 | del self._groups[self.findChild(QtWidgets.QComboBox, 187 | 'groups').currentText()] 188 | self._on_refresh() 189 | 190 | def _on_group_add(self): 191 | """Prompt the user for a name and add a new group.""" 192 | 193 | default = "New Group" 194 | i = 1 195 | 196 | while default in self._groups: 197 | i += 1 198 | default = "New Group #%d" % i 199 | 200 | name, okay = self._ask( 201 | title="Create a New Group", 202 | prompt="Please enter a name for your new group.", 203 | default=default, 204 | parent=self, 205 | ) 206 | 207 | name = okay and name.strip() 208 | if name: 209 | self._groups[name] = {'mode': 'random', 'presets': []} 210 | self._on_refresh(select=name) 211 | 212 | def _on_refresh(self, select=None): 213 | """Repopulate the group dropdown and initialize panel.""" 214 | 215 | groups = self.findChild(QtWidgets.QComboBox, 'groups') 216 | groups.clear() 217 | groups.addItem("View/Edit Group...") 218 | 219 | if self._groups: 220 | groups.setEnabled(True) 221 | groups.insertSeparator(1) 222 | groups.addItems(sorted(self._groups.keys(), 223 | key=lambda name: name.upper())) 224 | if select: 225 | idx = groups.findText(select) 226 | groups.setCurrentIndex(idx) 227 | self._on_group_activated(idx) 228 | else: 229 | self._on_group_activated(0) 230 | 231 | else: 232 | groups.setEnabled(False) 233 | self._on_group_activated(0) 234 | 235 | def accept(self): 236 | """Saves groups back to user configuration.""" 237 | 238 | self._pull_presets() 239 | self._addon.config['groups'] = { 240 | name: {'mode': group['mode'], 'presets': group['presets'][:]} 241 | for name, group in self._groups.items() 242 | } 243 | self._current_group = None 244 | super(Groups, self).accept() 245 | 246 | def reject(self): 247 | """Unset the current group.""" 248 | 249 | self._current_group = None 250 | super(Groups, self).reject() 251 | 252 | def _pull_presets(self): 253 | """Update current group's presets.""" 254 | 255 | name = self._current_group 256 | if not name or name not in self._groups: 257 | return 258 | 259 | list_view = self.findChild(QtWidgets.QListView, 'presets') 260 | for editor in list_view.findChildren(QtWidgets.QWidget, 'editor'): 261 | list_view.commitData(editor) # if an editor is open, save it 262 | 263 | self._groups[name]['presets'] = list_view.model().raw_data 264 | -------------------------------------------------------------------------------- /awesometts/gui/icons/arrow-down.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/arrow-down.png -------------------------------------------------------------------------------- /awesometts/gui/icons/arrow-up.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/arrow-up.png -------------------------------------------------------------------------------- /awesometts/gui/icons/clock16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/clock16.png -------------------------------------------------------------------------------- /awesometts/gui/icons/configure.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/configure.png -------------------------------------------------------------------------------- /awesometts/gui/icons/document-new.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/document-new.png -------------------------------------------------------------------------------- /awesometts/gui/icons/editclear.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/editclear.png -------------------------------------------------------------------------------- /awesometts/gui/icons/editdelete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/editdelete.png -------------------------------------------------------------------------------- /awesometts/gui/icons/emblem-favorite.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/emblem-favorite.png -------------------------------------------------------------------------------- /awesometts/gui/icons/fileclose.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/fileclose.png -------------------------------------------------------------------------------- /awesometts/gui/icons/find.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/find.png -------------------------------------------------------------------------------- /awesometts/gui/icons/kpersonalizer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/kpersonalizer.png -------------------------------------------------------------------------------- /awesometts/gui/icons/list-add.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/list-add.png -------------------------------------------------------------------------------- /awesometts/gui/icons/player-time.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/player-time.png -------------------------------------------------------------------------------- /awesometts/gui/icons/rating.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/rating.png -------------------------------------------------------------------------------- /awesometts/gui/icons/speaker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/awesometts/gui/icons/speaker.png -------------------------------------------------------------------------------- /awesometts/gui/presets.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """Presets management dialog""" 20 | 21 | from PyQt5 import QtWidgets 22 | 23 | from .base import ServiceDialog 24 | from .common import Label, Note 25 | 26 | __all__ = ['Presets'] 27 | 28 | 29 | class Presets(ServiceDialog): 30 | """Provides a dialog for editing presets.""" 31 | 32 | __slots__ = [] 33 | 34 | def __init__(self, *args, **kwargs): 35 | super(Presets, self).__init__(title="Manage Service Presets", 36 | *args, **kwargs) 37 | 38 | # UI Construction ######################################################## 39 | 40 | def _ui_control(self): 41 | """Add explanation of the preset functionality.""" 42 | 43 | header = Label("About Service Presets") 44 | header.setFont(self._FONT_HEADER) 45 | 46 | layout = super(Presets, self)._ui_control() 47 | layout.addWidget(header) 48 | layout.addWidget(Note( 49 | 'Once saved, your service option presets can be easily recalled ' 50 | 'in most AwesomeTTS dialog windows and/or used for on-the-fly ' 51 | 'playback with ... template tags.' 52 | )) 53 | layout.addWidget(Note( 54 | "Selecting text and then side-clicking in some Anki panels (e.g. " 55 | "review mode, card layout editor, note editor fields) will also " 56 | "allow playback of the selected text using any of your presets." 57 | )) 58 | layout.addSpacing(self._SPACING) 59 | layout.addStretch() 60 | layout.addWidget(self._ui_buttons()) 61 | 62 | return layout 63 | 64 | def _ui_buttons(self): 65 | """Removes the "Cancel" button.""" 66 | 67 | buttons = super(Presets, self)._ui_buttons() 68 | for btn in buttons.buttons(): 69 | if buttons.buttonRole(btn) == QtWidgets.QDialogButtonBox.RejectRole: 70 | buttons.removeButton(btn) 71 | return buttons 72 | 73 | # Events ################################################################# 74 | 75 | def accept(self): 76 | """Remember the user's options if they hit "Okay".""" 77 | 78 | self._addon.config.update(self._get_all()) 79 | super(Presets, self).accept() 80 | -------------------------------------------------------------------------------- /awesometts/gui/stripper.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Sound tag-stripper dialog 21 | """ 22 | 23 | from PyQt5 import QtCore, QtWidgets 24 | 25 | from .base import Dialog 26 | from .common import Checkbox, Label, Note 27 | 28 | __all__ = ['BrowserStripper'] 29 | 30 | 31 | class BrowserStripper(Dialog): 32 | """ 33 | Provides a dialog that can be invoked when the user wants to remove 34 | [sound] tags from a selection of notes in the card browser. 35 | """ 36 | 37 | __slots__ = [ 38 | '_alerts', # callable for reporting errors and summaries 39 | '_browser', # reference to the current Anki browser window 40 | '_notes', # list of Note objects selected when window opened 41 | ] 42 | 43 | def __init__(self, browser, alerts, *args, **kwargs): 44 | """ 45 | Sets our title and initializes our selected notes. 46 | """ 47 | 48 | self._alerts = alerts 49 | self._browser = browser 50 | self._notes = None # set in show() 51 | 52 | super(BrowserStripper, self).__init__( 53 | title="Remove Audio from Selected Notes", 54 | *args, **kwargs 55 | ) 56 | 57 | # UI Construction ######################################################## 58 | 59 | def _ui(self): 60 | """ 61 | Prepares the basic layout structure, including the intro label, 62 | scroll area, radio buttons, and help/okay/cancel buttons. 63 | """ 64 | 65 | intro = Note() # see show() for where the text is initialized 66 | intro.setObjectName('intro') 67 | 68 | scroll = QtWidgets.QScrollArea() 69 | scroll.setObjectName('scroll') 70 | 71 | layout = super(BrowserStripper, self)._ui() 72 | layout.addWidget(intro) 73 | layout.addWidget(scroll) 74 | layout.addSpacing(self._SPACING) 75 | layout.addWidget(Label("... and remove the following:")) 76 | 77 | for value, label in [ 78 | ( 79 | 'ours', 80 | "only [sound] tags or paths generated by Awesome&TTS", 81 | ), 82 | ( 83 | 'theirs', 84 | "only [sound] tags ¬ generated by AwesomeTTS", 85 | ), 86 | ( 87 | 'any', 88 | "&all [sound] tags, regardless of origin, and paths " 89 | "generated by AwesomeTTS", 90 | ), 91 | ]: 92 | radio = QtWidgets.QRadioButton(label) 93 | radio.setObjectName(value) 94 | layout.addWidget(radio) 95 | 96 | layout.addWidget(self._ui_buttons()) 97 | 98 | return layout 99 | 100 | def _ui_buttons(self): 101 | """ 102 | Adjust title of the OK button. 103 | """ 104 | 105 | buttons = super(BrowserStripper, self)._ui_buttons() 106 | buttons.findChild(QtWidgets.QAbstractButton, 'okay').setText("&Remove Now") 107 | 108 | return buttons 109 | 110 | # Events ################################################################# 111 | 112 | def show(self, *args, **kwargs): 113 | """ 114 | Populate the checkbox list of available fields and initialize 115 | the introduction message, both based on what is selected. 116 | """ 117 | 118 | self._notes = [ 119 | self._browser.mw.col.getNote(note_id) 120 | for note_id in self._browser.selectedNotes() 121 | ] 122 | 123 | self.findChild(Note, 'intro').setText( 124 | "From the %d note%s selected in the Browser, scan the following " 125 | "fields:" % 126 | (len(self._notes), "s" if len(self._notes) != 1 else "") 127 | ) 128 | 129 | layout = QtWidgets.QVBoxLayout() 130 | for field in sorted({field 131 | for note in self._notes 132 | for field in note.keys()}): 133 | checkbox = Checkbox(field) 134 | checkbox.atts_field_name = field 135 | layout.addWidget(checkbox) 136 | 137 | panel = QtWidgets.QWidget() 138 | panel.setLayout(layout) 139 | 140 | self.findChild(QtWidgets.QScrollArea, 'scroll').setWidget(panel) 141 | 142 | ( 143 | self.findChild( 144 | QtWidgets.QRadioButton, 145 | self._addon.config['last_strip_mode'], 146 | ) 147 | or self.findChild(QtWidgets.QRadioButton) # use first if config bad 148 | ).setChecked(True) 149 | 150 | super(BrowserStripper, self).show(*args, **kwargs) 151 | 152 | def help_request(self): 153 | """ 154 | Launch the web browser pointed at the subsection of the Browser 155 | page about stripping sounds. 156 | """ 157 | 158 | self._launch_link('usage/removing') 159 | 160 | def accept(self): 161 | """ 162 | Iterates over the selected notes and scans the checked fields 163 | for [sound] tags, stripping the ones requested by the user. 164 | """ 165 | 166 | fields = [ 167 | checkbox.atts_field_name 168 | for checkbox in self.findChildren(Checkbox) 169 | if checkbox.isChecked() 170 | ] 171 | 172 | if not fields: 173 | self._alerts("You must select at least one field.", self) 174 | return 175 | 176 | self.setDisabled(True) 177 | QtCore.QTimer.singleShot( 178 | 100, 179 | lambda: self._accept_process(fields), 180 | ) 181 | 182 | def _accept_process(self, fields): 183 | """ 184 | Backend processing for accept(), called after a delay. 185 | """ 186 | 187 | mode = next( 188 | radio.objectName() 189 | for radio in self.findChildren(QtWidgets.QRadioButton) 190 | if radio.isChecked() 191 | ) 192 | 193 | self._browser.mw.checkpoint("AwesomeTTS Sound Removal") 194 | 195 | stat = dict( 196 | notes=dict(proc=0, upd=0), 197 | fields=dict(proc=0, upd=0, skip=0), 198 | ) 199 | 200 | for note in self._notes: 201 | note_updated = False 202 | stat['notes']['proc'] += 1 203 | 204 | for field in fields: 205 | try: 206 | old_value = note[field] 207 | stat['fields']['proc'] += 1 208 | except KeyError: 209 | stat['fields']['skip'] += 1 210 | continue 211 | 212 | strips = self._addon.strip.sounds 213 | new_value = (strips.ours(old_value) if mode == 'ours' 214 | else strips.theirs(old_value) if mode == 'theirs' 215 | else strips.univ(old_value)) 216 | 217 | if old_value == new_value: 218 | self._addon.logger.debug("Note %d unchanged for %s\n%s", 219 | note.id, field, old_value) 220 | else: 221 | self._addon.logger.info("Note %d upd for %s\n%s\n%s", 222 | note.id, field, old_value, 223 | new_value) 224 | note[field] = new_value.strip() 225 | note_updated = True 226 | stat['fields']['upd'] += 1 227 | 228 | if note_updated: 229 | note.flush() 230 | stat['notes']['upd'] += 1 231 | 232 | messages = [ 233 | "%d %s processed and %d %s updated." % ( 234 | stat['notes']['proc'], 235 | "note was" if stat['notes']['proc'] == 1 else "notes were", 236 | stat['notes']['upd'], 237 | "was" if stat['notes']['upd'] == 1 else "were", 238 | ), 239 | ] 240 | 241 | if stat['notes']['proc'] != stat['fields']['proc']: 242 | messages.append("\nOf %s, %d %s checked and %d %s changed." % ( 243 | "this" if stat['notes']['proc'] == 1 else "these", 244 | stat['fields']['proc'], 245 | "field was" if stat['fields']['proc'] == 1 else "fields were", 246 | stat['fields']['upd'], 247 | "was" if stat['fields']['upd'] == 1 else "were", 248 | )) 249 | 250 | if stat['fields']['skip'] == 1: 251 | messages.append("\n1 field was not present on one of the notes.") 252 | elif stat['fields']['skip'] > 1: 253 | messages.append("\n%d fields were not present on some notes." % 254 | stat['fields']['skip']) 255 | 256 | if stat['notes']['upd']: 257 | messages.append("\n\n" 258 | "To purge sounds from your Anki collection that " 259 | 'are no longer used in any notes, select "Check ' 260 | 'Media" from the Anki "Tools" menu in the main ' 261 | "window.") 262 | 263 | self._browser.model.reset() 264 | self._addon.config['last_strip_mode'] = mode 265 | self.setDisabled(False) 266 | self._notes = None 267 | 268 | super(BrowserStripper, self).accept() 269 | 270 | # this alert is done by way of a singleShot() callback to avoid random 271 | # crashes on Mac OS X, which happen <5% of the time if called directly 272 | QtCore.QTimer.singleShot( 273 | 0, 274 | lambda: self._alerts("".join(messages), self._browser), 275 | ) 276 | -------------------------------------------------------------------------------- /awesometts/gui/templater.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Template generation dialog 21 | """ 22 | 23 | from PyQt5 import QtWidgets 24 | 25 | from .base import ServiceDialog 26 | from .common import Checkbox, Label, Note 27 | 28 | __all__ = ['Templater'] 29 | 30 | # all methods might need 'self' in the future, pylint:disable=R0201 31 | 32 | 33 | class Templater(ServiceDialog): 34 | """ 35 | Provides a dialog for building an on-the-fly TTS tag in Anki's card 36 | layout editor. 37 | """ 38 | 39 | HELP_USAGE_DESC = "Inserting on-the-fly playback tags into templates" 40 | 41 | HELP_USAGE_SLUG = 'on-the-fly' 42 | 43 | __slots__ = [ 44 | '_card_layout', # reference to the card layout window 45 | '_is_cloze', # True if the model attached 46 | ] 47 | 48 | def __init__(self, card_layout, *args, **kwargs): 49 | """ 50 | Sets our title. 51 | """ 52 | 53 | from anki.consts import MODEL_CLOZE 54 | self._card_layout = card_layout 55 | self._is_cloze = card_layout.model['type'] == MODEL_CLOZE 56 | 57 | super(Templater, self).__init__( 58 | title="Add On-the-Fly TTS Tag to Template", 59 | *args, **kwargs 60 | ) 61 | 62 | # UI Construction ######################################################## 63 | 64 | def _ui_control(self): 65 | """ 66 | Returns the superclass's text and preview buttons, adding our 67 | field input selector, then the base class's cancel/OK buttons. 68 | """ 69 | 70 | header = Label("Tag Options") 71 | header.setFont(self._FONT_HEADER) 72 | 73 | layout = super(Templater, self)._ui_control() 74 | layout.addWidget(header) 75 | layout.addWidget(Note("AwesomeTTS will speak tags as you " 76 | "review.")) 77 | layout.addStretch() 78 | layout.addLayout(self._ui_control_fields()) 79 | layout.addStretch() 80 | layout.addWidget(Note("This feature requires desktop Anki w/ " 81 | "AwesomeTTS installed; it will not work on " 82 | "mobile apps or AnkiWeb.")) 83 | layout.addStretch() 84 | layout.addWidget(self._ui_buttons()) 85 | 86 | return layout 87 | 88 | def _ui_control_fields(self): 89 | """ 90 | Returns a dropdown box to let the user select a source field. 91 | """ 92 | 93 | widgets = {} 94 | layout = QtWidgets.QGridLayout() 95 | 96 | for row, label, name, options in [ 97 | (0, "Field:", 'field', [ 98 | ('', "customize the tag's content"), 99 | ] + [ 100 | (field, field) 101 | for field in sorted({field['name'] 102 | for field 103 | in self._card_layout.model['flds']}) 104 | ]), 105 | 106 | (1, "Visibility:", 'hide', [ 107 | ('normal', "insert the tag as-is"), 108 | ('inline', "hide just this tag w/ inline CSS"), 109 | ('global', "add rule to hide any TTS tag for note type"), 110 | ]), 111 | 112 | (2, "Add to:", 'target', [ 113 | ('front', "Front Template"), 114 | ('back', "Back Template"), 115 | ]), 116 | 117 | # row 3 is used below if self._is_cloze is True 118 | ]: 119 | label = Label(label) 120 | label.setFont(self._FONT_LABEL) 121 | 122 | widgets[name] = self._ui_control_fields_dropdown(name, options) 123 | layout.addWidget(label, row, 0) 124 | layout.addWidget(widgets[name], row, 1) 125 | 126 | if self._is_cloze: 127 | cloze = Checkbox(object_name='cloze') 128 | cloze.setMinimumHeight(25) 129 | 130 | warning = Label("Remember 'cloze:' for any cloze fields.") 131 | warning.setMinimumHeight(25) 132 | 133 | layout.addWidget(cloze, 3, 1) 134 | layout.addWidget(warning, 3, 1) 135 | 136 | widgets['field'].setCurrentIndex(-1) 137 | widgets['field'].currentIndexChanged.connect(lambda index: ( 138 | cloze.setVisible(index), 139 | cloze.setText( 140 | "%s uses cloze" % 141 | (widgets['field'].itemData(index) if index else "this") 142 | ), 143 | warning.setVisible(not index), 144 | )) 145 | 146 | return layout 147 | 148 | def _ui_control_fields_dropdown(self, name, options): 149 | """ 150 | Returns a dropdown with the given list of options. 151 | """ 152 | 153 | dropdown = QtWidgets.QComboBox() 154 | dropdown.setObjectName(name) 155 | for value, label in options: 156 | dropdown.addItem(label, value) 157 | 158 | return dropdown 159 | 160 | def _ui_buttons(self): 161 | """ 162 | Adjust title of the OK button. 163 | """ 164 | 165 | buttons = super(Templater, self)._ui_buttons() 166 | buttons.findChild(QtWidgets.QAbstractButton, 'okay').setText("&Insert") 167 | 168 | return buttons 169 | 170 | # Events ################################################################# 171 | 172 | def show(self, *args, **kwargs): 173 | """ 174 | Restore the three dropdown's last known state and then focus the 175 | field dropdown. 176 | """ 177 | 178 | super(Templater, self).show(*args, **kwargs) 179 | 180 | for name in ['hide', 'target', 'field']: 181 | dropdown = self.findChild(QtWidgets.QComboBox, name) 182 | dropdown.setCurrentIndex(max( 183 | dropdown.findData(self._addon.config['templater_' + name]), 0 184 | )) 185 | 186 | if self._is_cloze: 187 | self.findChild(Checkbox, 'cloze') \ 188 | .setChecked(self._addon.config['templater_cloze']) 189 | 190 | dropdown.setFocus() # abuses fact that 'field' is last in the loop 191 | 192 | def accept(self): 193 | """ 194 | Given the user's selected service and options, assembles a TTS 195 | tag and then remembers the options. 196 | """ 197 | 198 | try: 199 | from html import escape 200 | except ImportError: 201 | from cgi import escape 202 | 203 | now = self._get_all() 204 | tform = self._card_layout.tform 205 | presets = self.findChild(QtWidgets.QComboBox, 'presets_dropdown') 206 | 207 | last_service = now['last_service'] 208 | attrs = ([('group', last_service[6:])] 209 | if last_service.startswith('group:') else 210 | [('preset', presets.currentText())] 211 | if presets.currentIndex() > 0 else 212 | [('service', last_service)] + 213 | sorted(now['last_options'][last_service].items())) 214 | if now['templater_hide'] == 'inline': 215 | attrs.append(('style', 'display: none')) 216 | attrs = ' '.join('%s="%s"' % (key, escape(str(value), quote=True)) 217 | for key, value in attrs) 218 | 219 | cloze = now.get('templater_cloze') 220 | field = now['templater_field'] 221 | html = ('' if not field 222 | else '{{cloze:%s}}' % field if cloze 223 | else '{{%s}}' % field) 224 | 225 | template = self._card_layout.current_template() 226 | extra_text = '%s' % (attrs, html) 227 | if now['templater_target'] == 'front': 228 | template["qfmt"] += '\n' + extra_text 229 | else: 230 | template["afmt"] += '\n' + extra_text 231 | 232 | if now['templater_hide'] == 'global': 233 | existing_css = self._card_layout.model["css"] 234 | extra_css = 'tts { display: none }' 235 | if existing_css.find(extra_css) < 0: 236 | self._card_layout.model["css"] = '\n'.join([ 237 | existing_css, 238 | extra_css, 239 | ]) 240 | 241 | self._card_layout.fill_fields_from_template() 242 | self._card_layout.renderPreview() 243 | 244 | self._addon.config.update(now) 245 | super(Templater, self).accept() 246 | 247 | def _get_all(self): 248 | """ 249 | Adds support to remember the three dropdowns and cloze state (if any), 250 | in addition to the service options handled by the superclass. 251 | """ 252 | 253 | combos = { 254 | name: widget.itemData(widget.currentIndex()) 255 | for name in ['field', 'hide', 'target'] 256 | for widget in [self.findChild(QtWidgets.QComboBox, name)] 257 | } 258 | 259 | return dict( 260 | list(super(Templater, self)._get_all().items()) + 261 | [('templater_' + name, value) for name, value in combos.items()] + 262 | ( 263 | [( 264 | 'templater_cloze', 265 | self.findChild(Checkbox, 'cloze').isChecked(), 266 | )] 267 | if self._is_cloze and combos['field'] 268 | else [] 269 | ) 270 | ) 271 | -------------------------------------------------------------------------------- /awesometts/gui/updater.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Updater dialog 21 | """ 22 | 23 | from time import time 24 | from PyQt5 import QtCore, QtWidgets, QtGui 25 | 26 | from ..paths import ICONS 27 | from .base import Dialog 28 | from .common import Note 29 | 30 | __all__ = ['Updater'] 31 | 32 | _NO_SCROLLBAR = QtCore.Qt.ScrollBarAlwaysOff 33 | 34 | 35 | class Updater(Dialog): 36 | """ 37 | Produces a dialog suitable for displaying to the user when an update 38 | is available, whether the user did a manual update check or if it 39 | was triggered at start-up. 40 | """ 41 | 42 | __slots__ = [ 43 | '_version', # latest version string for the add-on 44 | '_info', # dict with additional information about the update 45 | '_is_manual', # True if the update was triggered manually 46 | '_inhibited', # contains reason as a string if updates cannot occur 47 | ] 48 | 49 | def __init__(self, version, info, is_manual=False, *args, **kwargs): 50 | """ 51 | Builds the dialog with the given version and info. If the check 52 | is flagged as a manual one, the various deferment options are 53 | hidden. 54 | """ 55 | 56 | self._version = version 57 | self._info = info 58 | self._is_manual = is_manual 59 | self._inhibited = None 60 | self._set_inhibited(kwargs.get('addon')) 61 | 62 | super(Updater, self).__init__( 63 | title=f"AwesomeTTS v{version} Available", 64 | *args, **kwargs 65 | ) 66 | 67 | def _set_inhibited(self, addon): 68 | """ 69 | Sets a human-readable reason why automatic updates are not 70 | possible based on things present in the environment, if needed. 71 | """ 72 | 73 | if not self._info['auto']: 74 | self._inhibited = "This update cannot be applied automatically." 75 | 76 | elif addon.paths.is_link: 77 | self._inhibited = "Because you are using AwesomeTTS via a " \ 78 | "symlink, you should not update the add-on directly from " \ 79 | "the Anki user interface. However, if you are using the " \ 80 | "symlink to point to a clone of the code repository, you " \ 81 | "can use the git tool to pull in upstream updates." 82 | 83 | # UI Construction ######################################################## 84 | 85 | def _ui(self): 86 | """ 87 | Returns the superclass's banner follow by our update information 88 | and action buttons. 89 | """ 90 | 91 | layout = super(Updater, self)._ui() 92 | 93 | if self._info['intro']: 94 | layout.addWidget(Note(self._info['intro'])) 95 | 96 | if self._info['notes']: 97 | list_icon = QtGui.QIcon(f'{ICONS}/rating.png') 98 | 99 | list_widget = QtWidgets.QListWidget() 100 | for note in self._info['notes']: 101 | list_widget.addItem(QtWidgets.QListWidgetItem(list_icon, note)) 102 | list_widget.setHorizontalScrollBarPolicy(_NO_SCROLLBAR) 103 | list_widget.setWordWrap(True) 104 | layout.addWidget(list_widget) 105 | 106 | if self._info['synopsis']: 107 | layout.addWidget(Note(self._info['synopsis'])) 108 | 109 | if self._inhibited: 110 | inhibited = Note(self._inhibited) 111 | inhibited.setFont(self._FONT_INFO) 112 | 113 | layout.addSpacing(self._SPACING) 114 | layout.addWidget(inhibited) 115 | 116 | layout.addSpacing(self._SPACING) 117 | layout.addWidget(self._ui_buttons()) 118 | 119 | return layout 120 | 121 | def _ui_buttons(self): 122 | """ 123 | Returns a horizontal row of action buttons. Overrides the one 124 | from the superclass. 125 | """ 126 | 127 | buttons = QtWidgets.QDialogButtonBox() 128 | 129 | now_button = QtWidgets.QPushButton( 130 | QtGui.QIcon(f'{ICONS}/emblem-favorite.png'), 131 | "Update Now", 132 | ) 133 | now_button.setAutoDefault(False) 134 | now_button.setDefault(False) 135 | now_button.clicked.connect(self._update) 136 | 137 | if self._inhibited: 138 | now_button.setEnabled(False) 139 | 140 | if self._is_manual: 141 | later_button = QtWidgets.QPushButton( 142 | QtGui.QIcon(f'{ICONS}/fileclose.png'), 143 | "Don't Update", 144 | ) 145 | later_button.clicked.connect(self.reject) 146 | 147 | else: 148 | menu = QtWidgets.QMenu() 149 | menu.addAction("Remind Me Next Session", self._remind_session) 150 | menu.addAction("Remind Me Tomorrow", self._remind_tomorrow) 151 | menu.addAction("Remind Me in a Week", self._remind_week) 152 | menu.addAction("Skip v%s" % self._version, self._skip_version) 153 | menu.addAction("Stop Checking for Updates", self._disable) 154 | 155 | later_button = QtWidgets.QPushButton( 156 | QtGui.QIcon(f'{ICONS}/clock16.png'), 157 | "Not Now", 158 | ) 159 | later_button.setMenu(menu) 160 | 161 | later_button.setAutoDefault(False) 162 | later_button.setDefault(False) 163 | 164 | buttons.addButton(now_button, QtWidgets.QDialogButtonBox.YesRole) 165 | buttons.addButton(later_button, QtWidgets.QDialogButtonBox.NoRole) 166 | 167 | return buttons 168 | 169 | # Events ################################################################# 170 | 171 | def _update(self): 172 | """ 173 | Updates the add-on via the Anki interface. 174 | """ 175 | 176 | self.accept() 177 | 178 | if isinstance(self.parentWidget(), QtWidgets.QDialog): 179 | self.parentWidget().reject() 180 | 181 | dlb = self._addon.downloader 182 | 183 | try: 184 | class OurGetAddons(dlb.base): # see base, pylint:disable=R0903 185 | """ 186 | Creates a sort of jerry-rigged version of Anki's add-on 187 | downloader dialog (usually GetAddons, but configurable) 188 | such that an accept() call on it will download 189 | AwesomeTTS specifically. 190 | """ 191 | 192 | def __init__(self): 193 | dlb.superbase.__init__( # skip, pylint:disable=W0233 194 | self, 195 | *dlb.args, 196 | **dlb.kwargs 197 | ) 198 | 199 | for name, value in dlb.attrs.items(): 200 | setattr(self, name, value) 201 | 202 | addon_dialog = OurGetAddons() 203 | addon_dialog.accept() # see base, pylint:disable=E1101 204 | 205 | except Exception as exception: # catch all, pylint:disable=W0703 206 | msg = getattr(exception, 'message', default=str(exception)) 207 | dlb.fail( 208 | f"Unable to automatically update AwesomeTTS ({msg}); " 209 | f"you may want to restart Anki and then update the " 210 | f"add-on manually from the Tools menu.", 211 | "Not available by Updater._update" 212 | ) 213 | 214 | def _remind_session(self): 215 | """ 216 | Closes the dialog; add-on will automatically check next session. 217 | """ 218 | 219 | self.reject() 220 | 221 | def _remind_tomorrow(self): 222 | """ 223 | Bumps the postpone time by 24 hours before closing dialog. 224 | """ 225 | 226 | self._addon.config['updates_postpone'] = time() + 86400 227 | self.reject() 228 | 229 | def _remind_week(self): 230 | """ 231 | Bumps the postpone time by 7 days before closing dialog. 232 | """ 233 | 234 | self._addon.config['updates_postpone'] = time() + 604800 235 | self.reject() 236 | 237 | def _skip_version(self): 238 | """ 239 | Marks current version as ignored before closing dialog. 240 | """ 241 | 242 | self._addon.config['updates_ignore'] = self._version 243 | self.reject() 244 | 245 | def _disable(self): 246 | """ 247 | Disables the automatic updates flag before closing dialog. 248 | """ 249 | 250 | self._addon.config['updates_enabled'] = False 251 | self.reject() 252 | -------------------------------------------------------------------------------- /awesometts/paths.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Path and directory initialization 21 | """ 22 | 23 | import os 24 | import tempfile 25 | 26 | __all__ = [ 27 | 'ADDON', 28 | 'ADDON_IS_LINKED', 29 | 'CACHE', 30 | 'CONFIG', 31 | 'LOG', 32 | 'TEMP', 33 | 'ICONS' 34 | ] 35 | 36 | 37 | # n.b. When determining the code directory, abspath() is needed since 38 | # the __file__ constant is not a full path by itself. 39 | 40 | ADDON = os.path.dirname(os.path.abspath(__file__)) 41 | 42 | ADDON_IS_LINKED = os.path.islink(ADDON) 43 | 44 | BLANK = os.path.join(ADDON, 'blank.mp3') 45 | 46 | CACHE = os.path.join(ADDON, '.cache') 47 | 48 | ICONS = os.path.join(ADDON, 'gui/icons') 49 | 50 | os.makedirs(CACHE, exist_ok=True) 51 | 52 | ROOT = os.path.dirname(ADDON) 53 | 54 | CONFIG = os.path.join(ROOT, 'user_files', 'config.db') 55 | 56 | LOG = os.path.join(ADDON, 'addon.log') 57 | 58 | TEMP = tempfile.gettempdir() 59 | -------------------------------------------------------------------------------- /awesometts/player.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Playback interface, providing user-configured delays 21 | """ 22 | 23 | import inspect 24 | 25 | from .text import RE_FILENAMES 26 | 27 | __all__ = ['Player'] 28 | 29 | 30 | class Player(object): 31 | """Once instantiated, provides interfaces for playing audio.""" 32 | 33 | __slots__ = [ 34 | '_anki', # bundle with mw, native (play function), sound (module) 35 | '_blank', # path to a blank 1-second MP3 36 | '_config', # dict-like interface for looking up user configuration 37 | '_logger', # logger-like interface for debugging the Player instance 38 | ] 39 | 40 | def __init__(self, anki, blank, config, logger=None): 41 | self._anki = anki 42 | self._blank = blank 43 | self._config = config 44 | self._logger = logger 45 | 46 | def preview(self, path): 47 | """Play path with no delay, from preview button.""" 48 | 49 | self._insert_blanks(0, "preview mode", path) 50 | self._anki.native(path) 51 | 52 | def menu_click(self, path): 53 | """Play path with no delay, from context menu.""" 54 | 55 | self._insert_blanks(0, "context menu", path) 56 | self._anki.native(path) 57 | 58 | def otf_question(self, path): 59 | """ 60 | Plays path after the configured 'delay_questions_onthefly' 61 | seconds. 62 | """ 63 | 64 | self._insert_blanks(self._config['delay_questions_onthefly'], 65 | "on-the-fly automatic question", 66 | path) 67 | self._anki.native(path) 68 | 69 | def otf_answer(self, path): 70 | """ 71 | Plays path after the configured 'delay_answers_onthefly' 72 | seconds. 73 | """ 74 | 75 | self._insert_blanks(self._config['delay_answers_onthefly'], 76 | "on-the-fly automatic answer", 77 | path) 78 | self._anki.native(path) 79 | 80 | def otf_shortcut(self, path): 81 | """Play path with no delay.""" 82 | 83 | self._insert_blanks(0, "on-the-fly shortcut", path) 84 | self._anki.native(path) 85 | 86 | def native_wrapper(self, path): 87 | """ 88 | Provides a function that can be used as a wrapper around the 89 | native Anki playback interface. This is used in order to impose 90 | playback delays on [sound] tags while in review mode. 91 | """ 92 | 93 | if self._anki.mw.state != 'review': 94 | self._insert_blanks(0, "wrapped, non-review", path) 95 | 96 | elif next((True 97 | for frame in inspect.stack() 98 | if frame[3] in self.native_wrapper.BLACKLISTED_FRAMES), 99 | False): 100 | self._insert_blanks(0, "wrapped, blacklisted caller", path) 101 | 102 | elif self._anki.mw.reviewer.state == 'question': 103 | if RE_FILENAMES.search(path): 104 | self._insert_blanks( 105 | self._config['delay_questions_stored_ours'], 106 | "wrapped, AwesomeTTS sound on question side", 107 | path, 108 | ) 109 | 110 | else: 111 | self._insert_blanks( 112 | self._config['delay_questions_stored_theirs'], 113 | "wrapped, non-AwesomeTTS sound on question side", 114 | path, 115 | ) 116 | 117 | elif self._anki.mw.reviewer.state == 'answer': 118 | if RE_FILENAMES.search(path): 119 | self._insert_blanks( 120 | self._config['delay_answers_stored_ours'], 121 | "wrapped, AwesomeTTS sound on answer side", 122 | path, 123 | ) 124 | 125 | else: 126 | self._insert_blanks( 127 | self._config['delay_answers_stored_theirs'], 128 | "wrapped, non-AwesomeTTS sound on answer side", 129 | path, 130 | ) 131 | 132 | else: 133 | self._insert_blanks(0, "wrapped, unknown review state", path) 134 | 135 | self._anki.native(path) 136 | 137 | native_wrapper.BLACKLISTED_FRAMES = [ 138 | 'addMedia', # if the user adds media in review 139 | 'replayAudio', # if the user strikes R or F5 140 | ] 141 | 142 | def _insert_blanks(self, seconds, reason, path): 143 | """ 144 | Insert silence of the given seconds, unless Anki's queue has 145 | items in it already. 146 | """ 147 | 148 | try: 149 | from aqt.sound import av_player 150 | playQueue = av_player._enqueued 151 | except ImportError: 152 | if self._anki.sound.mpvManager is not None: 153 | playQueue = self._anki.sound.mpvManager.get_property("playlist-count") 154 | else: 155 | playQueue = self._anki.sound.mplayerQueue 156 | 157 | if playQueue: 158 | if self._logger: 159 | self._logger.debug("Ignoring %d-second delay (%s) because of " 160 | "queue: %s", seconds, reason, path) 161 | return 162 | 163 | if self._logger: 164 | self._logger.debug("Need %d-second delay (%s): %s", 165 | seconds, reason, path) 166 | for _ in range(seconds): 167 | self._anki.native(self._blank) 168 | -------------------------------------------------------------------------------- /awesometts/service/__init__.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service classes for AwesomeTTS 21 | """ 22 | 23 | from .common import Trait 24 | 25 | from .abair import Abair 26 | from .azuretts import AzureTTS 27 | from .baidu import Baidu 28 | from .collins import Collins 29 | from .duden import Duden 30 | from .ekho import Ekho 31 | from .espeak import ESpeak 32 | from .festival import Festival 33 | from .fluencynl import FluencyNl 34 | from .google import Google 35 | from .googletts import GoogleTTS 36 | from .howjsay import Howjsay 37 | from .imtranslator import ImTranslator 38 | from .ispeech import ISpeech 39 | from .naver import Naver 40 | from .neospeech import NeoSpeech 41 | # from .oddcast import Oddcast 42 | from .oxford import Oxford 43 | from .pico2wave import Pico2Wave 44 | from .rhvoice import RHVoice 45 | from .sapi5com import SAPI5COM 46 | from .sapi5js import SAPI5JS 47 | from .say import Say 48 | from .spanishdict import SpanishDict 49 | from .voicetext import VoiceText 50 | from .wiktionary import Wiktionary 51 | from .yandex import Yandex 52 | from .youdao import Youdao 53 | 54 | __all__ = [ 55 | # common 56 | 'Trait', 57 | 58 | # services 59 | 'Abair', 60 | 'AzureTTS', 61 | 'Baidu', 62 | 'Collins', 63 | 'Duden', 64 | 'Ekho', 65 | 'ESpeak', 66 | 'Festival', 67 | 'FluencyNl', 68 | 'Google', 69 | 'GoogleTTS', 70 | 'Howjsay', 71 | 'ImTranslator', 72 | 'ISpeech', 73 | 'Naver', 74 | 'NeoSpeech', 75 | # 'Oddcast', 76 | 'Oxford', 77 | 'Pico2Wave', 78 | 'RHVoice', 79 | 'SAPI5COM', 80 | 'SAPI5JS', 81 | 'Say', 82 | 'SpanishDict', 83 | 'VoiceText', 84 | 'Wiktionary', 85 | 'Yandex', 86 | 'Youdao', 87 | ] 88 | -------------------------------------------------------------------------------- /awesometts/service/abair.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for abair.ie's Irish language synthesiser 21 | """ 22 | 23 | from re import compile as re_compile 24 | 25 | from .base import Service 26 | from .common import Trait 27 | 28 | __all__ = ['Abair'] 29 | 30 | 31 | VOICES = [('gd', "Gweedore"), ('cm_V2', "Connemara"), ('hts', "Connemara HTS"), 32 | ('ga_MU_nnc_exthts', "Dingle Pen. HTS")] 33 | 34 | SPEEDS = ["Very slow", "Slower", "Normal", "Faster", "Very fast"] 35 | DEFAULT_SPEED = "Normal" 36 | assert DEFAULT_SPEED in SPEEDS 37 | 38 | TEXT_LENGTH_LIMIT = 2000 39 | FORM_ENDPOINT = 'http://www.abair.tcd.ie/index.php' 40 | RE_FILENAME = re_compile(r'name="filestozip" type="hidden" value="([\d_]+)"') 41 | AUDIO_URL = 'http://www.abair.tcd.ie/audio/%s.mp3' 42 | REQUIRE_MP3 = dict(mime='audio/mpeg', size=256) 43 | 44 | 45 | class Abair(Service): 46 | """Provides a Service-compliant implementation for abair.ie.""" 47 | 48 | __slots__ = [] 49 | 50 | NAME = "abair.ie" 51 | 52 | TRAITS = [Trait.INTERNET] 53 | 54 | def desc(self): 55 | """Returns a short, static description.""" 56 | 57 | return "abair.ie's Irish language synthesiser" 58 | 59 | def options(self): 60 | """Provides access to voice and speed.""" 61 | 62 | voice_lookup = {self.normalize(value): value for value, _ in VOICES} 63 | speed_lookup = {self.normalize(value): value for value in SPEEDS} 64 | 65 | return [ 66 | dict( 67 | key='voice', 68 | label="Voice", 69 | values=VOICES, 70 | transform=lambda value: voice_lookup.get(self.normalize(value), 71 | value), 72 | ), 73 | 74 | dict( 75 | key='speed', 76 | label="Speed", 77 | values=[(value, value) for value in SPEEDS], 78 | transform=lambda value: speed_lookup.get(self.normalize(value), 79 | value), 80 | default=DEFAULT_SPEED, 81 | ), 82 | ] 83 | 84 | def run(self, text, options, path): 85 | """Find audio filename and then download it.""" 86 | 87 | if len(text) > TEXT_LENGTH_LIMIT: 88 | raise IOError("abair.ie only supports input up to %d characters." % 89 | TEXT_LENGTH_LIMIT) 90 | 91 | payload = self.net_stream( 92 | ( 93 | FORM_ENDPOINT, 94 | dict( 95 | input=text, 96 | speed=options['speed'], 97 | synth=options['voice'], 98 | ), 99 | ), 100 | method='POST', 101 | ).decode() 102 | 103 | match = RE_FILENAME.search(payload) 104 | if not match: 105 | raise IOError("Cannot find sound file in response from abair.ie") 106 | 107 | self.net_download( 108 | path, 109 | AUDIO_URL % match.group(1), 110 | require=REQUIRE_MP3, 111 | ) 112 | -------------------------------------------------------------------------------- /awesometts/service/baidu.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Baidu Translate's text-to-speech API 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Baidu'] 27 | 28 | 29 | VOICES = { 30 | 'en': "English, American", 31 | 'jp': "Japanese", 32 | 'pt': "Portuguese", 33 | # returns error -- 'spa': "Spanish", 34 | 'th': "Thai", 35 | 'uk': "English, British", 36 | 'zh': "Chinese", 37 | } 38 | 39 | 40 | class Baidu(Service): 41 | """ 42 | Provides a Service-compliant implementation for Baidu Translate. 43 | """ 44 | 45 | __slots__ = [] 46 | 47 | NAME = "Baidu Translate" 48 | 49 | TRAITS = [Trait.INTERNET] 50 | 51 | def desc(self): 52 | """Returns a short, static description.""" 53 | 54 | return "Baidu Translate text2audio web API (%d voices)" % len(VOICES) 55 | 56 | def options(self): 57 | """Provides access to voice only.""" 58 | 59 | return [ 60 | dict( 61 | key='voice', 62 | label="Voice", 63 | values=[(code, "%s (%s)" % (name, code)) 64 | for code, name 65 | in sorted(VOICES.items(), key=lambda t: t[1])], 66 | transform=self.normalize, 67 | ), 68 | ] 69 | 70 | def run(self, text, options, path): 71 | """Downloads from Baidu directly to an MP3.""" 72 | 73 | self.net_download( 74 | path, 75 | [ 76 | ('http://tts.baidu.com/text2audio', 77 | dict(text=subtext, lan=options['voice'], ie='UTF-8')) 78 | for subtext in self.util_split(text, 300) 79 | ], 80 | require=dict(size=512), 81 | ) 82 | -------------------------------------------------------------------------------- /awesometts/service/collins.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for the Collins Dictionary 21 | """ 22 | 23 | import re 24 | 25 | from .base import Service 26 | from .common import Trait 27 | 28 | __all__ = ['Collins'] 29 | 30 | 31 | BASE_PATTERN = r'data-src-mp3="(?:https://www.collinsdictionary.com)(/sounds/[\w/]+/%s\w*\.mp3)"' 32 | RE_ANY_SPANISH = re.compile(BASE_PATTERN % r'es_') 33 | 34 | MAPPINGS = [ 35 | ('en', "English", 'english', [re.compile(BASE_PATTERN % r'\d+')]), 36 | ('fr', "French", 'french-english', [re.compile(BASE_PATTERN % r'fr_')]), 37 | ('de', "German", 'german-english', [re.compile(BASE_PATTERN % r'de_')]), 38 | ('es-419', "Spanish, prefer Americas", 'spanish-english', 39 | [re.compile(BASE_PATTERN % r'es_419_'), RE_ANY_SPANISH]), 40 | ('es-es', "Spanish, prefer European", 'spanish-english', 41 | [re.compile(BASE_PATTERN % r'es_es_'), RE_ANY_SPANISH]), 42 | ('it', "Italian", 'italian-english', [re.compile(BASE_PATTERN % r'it_')]), 43 | ('zh', "Chinese", 'chinese-english', [re.compile(BASE_PATTERN % r'zh_')]), 44 | ] 45 | 46 | LANG_TO_DICTCODE = {lang: dictcode for lang, _, dictcode, _ in MAPPINGS} 47 | LANG_TO_REGEXPS = {lang: regexps for lang, _, _, regexps in MAPPINGS} 48 | DEFAULT_LANG = 'en' 49 | 50 | RE_NONWORD = re.compile(r'\W+', re.UNICODE) 51 | DEFINITE_ARTICLES = ['das', 'der', 'die', 'el', 'gli', 'i', 'il', 'l', 'la', 52 | 'las', 'le', 'les', 'lo', 'los', 'the'] 53 | 54 | TEXT_SPACE_LIMIT = 1 55 | TEXT_LENGTH_LIMIT = 75 56 | COLLINS_WEBSITE = 'http://www.collinsdictionary.com' 57 | SEARCH_FORM = COLLINS_WEBSITE + '/search/' 58 | RE_MP3_URL = re.compile(r']+class="[^>"]*hwd_sound[^>"]*"[^>]+' 59 | r'data-src-mp3="(/[^>"]+)"[^>]*>') 60 | REQUIRE_MP3 = dict(mime='audio/mpeg', size=256) 61 | 62 | 63 | class Collins(Service): 64 | """Provides a Service-compliant implementation for Collins.""" 65 | 66 | __slots__ = [] 67 | 68 | NAME = "Collins" 69 | 70 | TRAITS = [Trait.INTERNET, Trait.DICTIONARY] 71 | 72 | def desc(self): 73 | """Returns a short, static description.""" 74 | 75 | return "Collins Dictionary (%d languages); single words and " \ 76 | "two-word phrases only with fuzzy matching" % len(MAPPINGS) 77 | 78 | def options(self): 79 | """Provides access to voice only.""" 80 | 81 | voice_lookup = dict([(self.normalize(desc), lang) 82 | for lang, desc, _, _ in MAPPINGS] + 83 | [(self.normalize(lang), lang) 84 | for lang, _, _, _ in MAPPINGS]) 85 | 86 | return [ 87 | dict( 88 | key='voice', 89 | label="Voice", 90 | values=[(lang, desc) for lang, desc, _, _ in MAPPINGS], 91 | transform=lambda value: voice_lookup.get(self.normalize(value), 92 | value), 93 | default=DEFAULT_LANG, 94 | ), 95 | ] 96 | 97 | def modify(self, text): 98 | """ 99 | Remove punctuation and return as lowercase. 100 | 101 | If the input is multiple words and the first word is a definite 102 | article, drop it. 103 | """ 104 | 105 | text = RE_NONWORD.sub('_', text).replace('_', ' ').strip().lower() 106 | 107 | tokenized = text.split(' ', 1) 108 | if len(tokenized) == 2: 109 | first, rest = tokenized 110 | if first in DEFINITE_ARTICLES: 111 | return rest 112 | 113 | return text 114 | 115 | def run(self, text, options, path): 116 | """Find audio filename and then download it.""" 117 | 118 | if text.count(' ') > TEXT_SPACE_LIMIT: 119 | raise IOError("The Collins Dictionary does not support phrases") 120 | elif len(text) > TEXT_LENGTH_LIMIT: 121 | raise IOError("The Collins Dictionary only supports short input") 122 | 123 | voice = options['voice'] 124 | 125 | payload = self.net_stream( 126 | (SEARCH_FORM, dict(q=text, dictCode=LANG_TO_DICTCODE[voice])), 127 | method='GET', 128 | ).decode() 129 | 130 | for regexp in LANG_TO_REGEXPS[voice]: 131 | self._logger.debug("Collins: trying pattern %s", regexp.pattern) 132 | 133 | match = regexp.search(payload) 134 | if match: 135 | self.net_download(path, 136 | COLLINS_WEBSITE + match.group(1), 137 | require=REQUIRE_MP3) 138 | break 139 | 140 | else: 141 | raise IOError("Cannot find any recorded audio in Collins " 142 | "dictionary for this input.") 143 | -------------------------------------------------------------------------------- /awesometts/service/common.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Common classes for services 21 | 22 | Provides an enum-like Trait class for specifying the characteristics of 23 | a service. 24 | """ 25 | 26 | __all__ = ['Trait'] 27 | 28 | 29 | class Trait(object): # enum class, pylint:disable=R0903 30 | """ 31 | Provides an enum-like namespace with codes that describe how a 32 | service works, used by concrete Service classes' TRAITS lists. 33 | 34 | The framework can query the registered Service classes to alter 35 | on-screen descriptions (e.g. inform the user which services make use 36 | of the LAME transcoder) or alter behavior (e.g. throttling when 37 | recording many media files from an online service). 38 | """ 39 | 40 | INTERNET = 1 # files retrieved from Internet; use throttling 41 | TRANSCODING = 2 # LAME transcoder is used 42 | DICTIONARY = 4 # for services that have limited vocabularies 43 | -------------------------------------------------------------------------------- /awesometts/service/ekho.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Ekho text-to-speech engine 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Ekho'] 27 | 28 | 29 | class Ekho(Service): 30 | """ 31 | Provides a Service-compliant implementation for Ekho. 32 | """ 33 | 34 | __slots__ = [ 35 | '_voice_list', # list of installed voices as a list of tuples 36 | ] 37 | 38 | NAME = "Ekho" 39 | 40 | TRAITS = [Trait.TRANSCODING] 41 | 42 | def __init__(self, *args, **kwargs): 43 | """ 44 | Attempts to read the list of voices from the `ekho --help` 45 | output. 46 | """ 47 | 48 | super(Ekho, self).__init__(*args, **kwargs) 49 | 50 | output = self.cli_output('ekho', '--help') 51 | 52 | import re 53 | re_list = re.compile(r'(language|voice).+available', re.IGNORECASE) 54 | re_voice = re.compile(r"'(\w+)'") 55 | 56 | self._voice_list = sorted({ 57 | ( 58 | # Workaround for Korean: in at least ekho v5.8.2, passing 59 | # `--voice Hangul` fails, but `--voice hangul` works. This is 60 | # different from the other voices that either only work when 61 | # capitalized (e.g. Mandarin, Cantonese) or accept both forms 62 | # (e.g. hakka/Hakka, ngangien/Ngangien, tibetan/Tibetan). 63 | 64 | 'hangul' if capture == 'Hangul' else capture, 65 | capture, 66 | ) 67 | for line in output if re_list.search(line) 68 | for capture in re_voice.findall(line) 69 | }, key=lambda voice: voice[1].lower()) 70 | 71 | if not self._voice_list: 72 | raise EnvironmentError("No usable output from `ekho --help`") 73 | 74 | def desc(self): 75 | """ 76 | Returns a simple version using `ekho --version`. 77 | """ 78 | 79 | return "ekho %s (%d voices)" % ( 80 | self.cli_output('ekho', '--version').pop(0), 81 | len(self._voice_list), 82 | ) 83 | 84 | def options(self): 85 | """ 86 | Provides access to voice, speed, pitch, rate, and volume. 87 | """ 88 | 89 | voice_lookup = { 90 | self.normalize(voice[0]): voice[0] 91 | for voice in self._voice_list 92 | } 93 | 94 | def transform_voice(value): 95 | """Normalize and attempt to convert to official voice.""" 96 | 97 | normalized = self.normalize(value) 98 | 99 | return ( 100 | voice_lookup[normalized] if normalized in voice_lookup 101 | 102 | else voice_lookup['mandarin'] if ( 103 | 'mandarin' in voice_lookup and 104 | normalized in ['cmn', 'cosc', 'goyu', 'huyu', 'mand', 105 | 'zh', 'zhcn'] 106 | ) 107 | 108 | else voice_lookup['cantonese'] if ( 109 | 'cantonese' in voice_lookup and 110 | normalized in ['cant', 'guzh', 'yue', 'yyef', 'zhhk', 111 | 'zhyue'] 112 | ) 113 | 114 | else voice_lookup['hakka'] if ( 115 | 'hakka' in voice_lookup and 116 | normalized in ['hak', 'hakk', 'kejia'] 117 | ) 118 | 119 | else voice_lookup['tibetan'] if ( 120 | 'tibetan' in voice_lookup and 121 | normalized in ['cent', 'west'] 122 | ) 123 | 124 | else voice_lookup['hangul'] if ( 125 | 'hangul' in voice_lookup and 126 | normalized in ['ko', 'kor', 'kore', 'korean'] 127 | ) 128 | 129 | else value 130 | ) 131 | 132 | voice_option = dict( 133 | key='voice', 134 | label="Voice", 135 | values=self._voice_list, 136 | transform=transform_voice, 137 | ) 138 | 139 | if 'mandarin' in voice_lookup: # default is Mandarin, if we have it 140 | voice_option['default'] = voice_lookup['mandarin'] 141 | 142 | return [ 143 | voice_option, 144 | 145 | dict( 146 | key='speed', 147 | label="Speed Delta", 148 | values=(-50, 300, "%"), 149 | transform=int, 150 | default=0, 151 | ), 152 | 153 | dict( 154 | key='pitch', 155 | label="Pitch Delta", 156 | values=(-100, 100, "%"), 157 | transform=int, 158 | default=0, 159 | ), 160 | 161 | dict( 162 | key='rate', 163 | label="Rate Delta", 164 | values=(-50, 100, "%"), 165 | transform=int, 166 | default=0, 167 | ), 168 | 169 | dict( 170 | key='volume', 171 | label="Volume Delta", 172 | values=(-100, 100, "%"), 173 | transform=int, 174 | default=0, 175 | ), 176 | ] 177 | 178 | def run(self, text, options, path): 179 | """ 180 | Checks for unicode workaround on Windows, writes a temporary 181 | wave file, and then transcodes to MP3. 182 | 183 | Technically speaking, Ekho supports writing directly to MP3, but 184 | by going through LAME, we can apply the user's custom flags. 185 | """ 186 | 187 | input_file = self.path_workaround(text) 188 | output_wav = self.path_temp('wav') 189 | 190 | try: 191 | self.cli_call( 192 | [ 193 | 'ekho', 194 | '-v', options['voice'], 195 | '-s', options['speed'], 196 | '-p', options['pitch'], 197 | '-r', options['rate'], 198 | '-a', options['volume'], 199 | '-o', output_wav, 200 | ] + ( 201 | ['-f', input_file] if input_file 202 | else ['--', text] 203 | ) 204 | ) 205 | 206 | self.cli_transcode( 207 | output_wav, 208 | path, 209 | require=dict( 210 | size_in=4096, 211 | ), 212 | ) 213 | 214 | finally: 215 | self.path_unlink(input_file, output_wav) 216 | -------------------------------------------------------------------------------- /awesometts/service/festival.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Festival Speech Synthesis System 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Festival'] 27 | 28 | 29 | class Festival(Service): 30 | """ 31 | Provides a Service-compliant implementation for Festival. 32 | """ 33 | 34 | __slots__ = [ 35 | '_version', # we get this while testing for the festival binary 36 | '_voice_list', # list of installed voices as a list of tuples 37 | ] 38 | 39 | NAME = "Festival" 40 | 41 | TRAITS = [Trait.TRANSCODING] 42 | 43 | def __init__(self, *args, **kwargs): 44 | """ 45 | Verifies existence of the `festival` and `text2wave` binaries 46 | and scans `/usr/share/festival/voices` for available voices. 47 | 48 | TODO: Is it possible to get Festival on Windows or Mac OS X? If 49 | so, what paths or binary location differences might there be? 50 | """ 51 | 52 | if not self.IS_LINUX: 53 | raise EnvironmentError( 54 | "AwesomeTTS only knows how to work with the Linux version of " 55 | "Festival at this time." 56 | ) 57 | 58 | super(Festival, self).__init__(*args, **kwargs) 59 | 60 | self._version = self.cli_output('festival', '--version').pop(0) 61 | self.cli_call('text2wave', '--help') 62 | 63 | import os 64 | 65 | def listdir(path): 66 | """try os.listdir() but return [] if exception""" 67 | try: 68 | return os.listdir(path) 69 | except OSError: 70 | return [] 71 | 72 | base_dirs = ['/usr/share/festival/voices', 73 | '/usr/local/share/festival/voices'] 74 | self._voice_list = list(set( 75 | (voice_dir, "%s (%s)" % (voice_dir, lang_dir)) 76 | for base_dir in base_dirs 77 | for lang_dir in sorted(listdir(base_dir)) 78 | if os.path.isdir(os.path.join(base_dir, lang_dir)) 79 | for voice_dir in sorted(listdir(os.path.join(base_dir, lang_dir))) 80 | if os.path.isdir(os.path.join(base_dir, lang_dir, voice_dir)) 81 | )) 82 | 83 | if not self._voice_list: 84 | raise EnvironmentError("No usable voices found") 85 | 86 | def desc(self): 87 | """ 88 | Returns a version string with terse description and release 89 | date, obtained when verifying the existence of the `festival` 90 | binary. 91 | """ 92 | 93 | return "%s (%d voices)" % (self._version, len(self._voice_list)) 94 | 95 | def options(self): 96 | """ 97 | Provides access to voice and volume. 98 | """ 99 | 100 | voice_lookup = { 101 | self.normalize(voice[0]): voice[0] 102 | for voice in self._voice_list 103 | } 104 | 105 | def transform_voice(value): 106 | """Normalize and attempt to convert to official voice.""" 107 | 108 | normalized = self.normalize(value) 109 | 110 | return ( 111 | voice_lookup[normalized] if normalized in voice_lookup 112 | else value 113 | ) 114 | 115 | return [ 116 | dict( 117 | key='voice', 118 | label="Voice", 119 | values=self._voice_list, 120 | transform=transform_voice, 121 | ), 122 | 123 | dict( 124 | key='volume', 125 | label="Volume", 126 | values=(10, 250, "%"), 127 | transform=int, 128 | default=100, 129 | ), 130 | ] 131 | 132 | def run(self, text, options, path): 133 | """ 134 | Write a temporary input text file, calls `text2wave` to write a 135 | temporary wave file, and then transcodes that to MP3. 136 | """ 137 | 138 | input_file = self.path_input(text) 139 | output_wav = self.path_temp('wav') 140 | 141 | try: 142 | self.cli_call( 143 | 'text2wave', 144 | '-o', output_wav, 145 | '-eval', '(voice_%s)' % options['voice'], 146 | '-scale', options['volume'] / 100.0, 147 | input_file, 148 | ) 149 | 150 | self.cli_transcode( 151 | output_wav, 152 | path, 153 | require=dict( 154 | size_in=4096, 155 | ), 156 | ) 157 | 158 | finally: 159 | self.path_unlink(input_file, output_wav) 160 | -------------------------------------------------------------------------------- /awesometts/service/fluencynl.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Fluency.nl text-to-speech demo 21 | """ 22 | 23 | from urllib.parse import quote 24 | 25 | from .base import Service 26 | from .common import Trait 27 | 28 | __all__ = ['FluencyNl'] 29 | 30 | 31 | VOICES = [ 32 | # short value, API value, human-readable 33 | ('arno', 'Arno', "Arno (male)"), 34 | ('arthur', 'Arthur', "Arthur (male)"), 35 | ('marco', 'Marco', "Marco (male)"), 36 | ('rob', 'Rob', "Rob (male)"), 37 | ('janneke', 'Janneke', "Janneke (female)"), 38 | ('miriam', 'Miriam', "Miriam (female)"), 39 | ('david', 'Dav%EDd%20%2813%20jaar%29', "Davíd (male, 13)"), 40 | ('giovanni', 'Giovanni%20%2815%20jaar%29', "Giovanni (male, 15)"), 41 | ('koen', 'Koen%20%2814%20jaar%29', "Koen (male, 14)"), 42 | ('fiona', 'Fiona%20%2816%20jaar%29', "Fiona (female, 16)"), 43 | ('dirk', 'Dirk%20%28Vlaams%29', "Dirk (Flemish, male)"), 44 | ('linze', 'Linze%20%28Fries%29', "Linze (Frisian, male)"), 45 | ('siebren', 'Siebren%20%28Fries%29', "Siebren (Frisian, male)"), 46 | ('sjoukje', 'Sjoukje%20%28Fries%29', "Sjoukje (Frisian, female)"), 47 | ('sanghita', 'Sanghita%20%28Surinaams%29', "Sanghita (Sranan)"), 48 | ('fluisterste', 'Fluisterstem', "Fluisterstem"), 49 | ('arthur-m', 'Arthur%20%28MBROLA%29', "Arthur (male) [MBROLA]"), 50 | ('johan-m', 'Johan%20%28MBROLA%29', "Johan (male) [MBROLA]"), 51 | ('tom-m', 'Tom%20%28MBROLA%29', "Tom (male) [MBROLA]"), 52 | ('diana-m', 'Diana%20%28MBROLA%29', "Diana (female) [MBROLA]"), 53 | ('isabelle-m', 'Isabelle%20%28MBROLA%29', "Isabelle (female) [MBROLA]"), 54 | ('gekko-m', 'Gekko%20%28MBROLA%29', "Gekko [MBROLA]"), 55 | ] 56 | 57 | VOICE_MAP = {voice[0]: voice for voice in VOICES} 58 | 59 | SPEEDS = [(-10, "slowest"), (-8, "very slow"), (-6, "slower"), (-2, "slow"), 60 | (0, "normal"), (2, "fast"), (6, "faster"), (8, "very fast"), 61 | (10, "fastest")] 62 | 63 | SPEED_VALUES = [value for value, label in SPEEDS] 64 | 65 | 66 | def _quoter(user_string): 67 | """ 68 | n.b. This quoter throws away some characters that are not in 69 | latin-1, like curly quotes (which the Flash version encodes as 70 | `%u201C` and `%u201D`), which is probably fine for 99.99% of use 71 | cases 72 | """ 73 | 74 | return quote(user_string.encode('latin-1', 'ignore').decode()) 75 | 76 | 77 | class FluencyNl(Service): 78 | """ 79 | Provides a Service-compliant implementation for Fluency.nl's 80 | text-to-speech demo. 81 | """ 82 | 83 | __slots__ = [] 84 | 85 | NAME = "Fluency.nl" 86 | 87 | TRAITS = [Trait.INTERNET] 88 | 89 | def desc(self): 90 | """Returns service name with a voice count.""" 91 | 92 | return "Fluency.nl Demo for Dutch (%d voices)" % len(VOICES) 93 | 94 | def options(self): 95 | """Provides access to voice and speed.""" 96 | 97 | voice_lookup = {self.normalize(key): key for key in VOICE_MAP.keys()} 98 | 99 | def transform_voice(user_value): 100 | """Tries to figure out our short value from user input.""" 101 | 102 | normalized_value = self.normalize(user_value.replace('í', 'i')) 103 | if normalized_value in voice_lookup: 104 | return voice_lookup[normalized_value] 105 | 106 | normalized_value += 'm' # MBROLA variation? 107 | if normalized_value in voice_lookup: 108 | return voice_lookup[normalized_value] 109 | 110 | return user_value 111 | 112 | def transform_speed(user_value): 113 | """Rounds user value to closest official value.""" 114 | 115 | user_value = float(user_value) 116 | return min( 117 | SPEED_VALUES, 118 | key=lambda official_value: abs(user_value - official_value), 119 | ) 120 | 121 | return [ 122 | dict( 123 | key='voice', 124 | label="Voice", 125 | values=[(short_value, human_description) 126 | for short_value, _, human_description 127 | in VOICES], 128 | transform=transform_voice, 129 | ), 130 | 131 | dict( 132 | key='speed', 133 | label="Speed", 134 | values=SPEEDS, 135 | transform=transform_speed, 136 | default=0, 137 | ), 138 | ] 139 | 140 | def run(self, text, options, path): 141 | """Downloads from Fluency.nl directly to an MP3.""" 142 | 143 | api_voice_value = VOICE_MAP[options['voice']][1] 144 | api_speed_value = options['speed'] 145 | 146 | self.net_download( 147 | path, 148 | [ 149 | ( 150 | 'http://www.fluency-server.nl/cgi-bin/speak.exe', 151 | dict( 152 | id='Fluency', 153 | voice=api_voice_value, 154 | text=_quoter(subtext), # intentionally double-encoded 155 | tempo=api_speed_value, 156 | rtf=50, 157 | ), 158 | ) 159 | for subtext in self.util_split(text, 250) 160 | ], 161 | method='POST', 162 | custom_headers=dict(Referer='http://www.fluency.nl/speak.swf'), 163 | require=dict(mime='audio/mpeg', size=256), 164 | add_padding=True, 165 | ) 166 | -------------------------------------------------------------------------------- /awesometts/service/howjsay.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Howjsay 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Howjsay'] 27 | 28 | 29 | class Howjsay(Service): 30 | """ 31 | Provides a Service-compliant implementation for Howjsay. 32 | """ 33 | 34 | __slots__ = [ 35 | ] 36 | 37 | NAME = "Howjsay" 38 | 39 | TRAITS = [Trait.INTERNET, Trait.DICTIONARY] 40 | 41 | def __init__(self, *args, **kwargs): 42 | super(Howjsay, self).__init__(*args, **kwargs) 43 | 44 | def desc(self): 45 | """ 46 | Returns a short, static description. 47 | """ 48 | 49 | return "Howjsay (English only, single words and short phrases only)" 50 | 51 | def options(self): 52 | """ 53 | Advertises English, but does not allow any configuration. 54 | """ 55 | 56 | return [ 57 | dict( 58 | key='voice', 59 | label="Voice", 60 | values=[ 61 | ('en', "English (en)"), 62 | ], 63 | transform=lambda value: ( 64 | 'en' if self.normalize(value).startswith('en') 65 | else value 66 | ), 67 | default='en', 68 | ), 69 | ] 70 | 71 | def modify(self, text): 72 | """ 73 | Approximate all accented characters as ASCII ones and then drop 74 | non-alphanumeric characters (except certain symbols). 75 | """ 76 | 77 | return ''.join( 78 | char 79 | for char in self.util_approx(text) 80 | if char.isalpha() or char.isdigit() or char in " '-.@" 81 | ).lower().strip() 82 | 83 | def run(self, text, options, path): 84 | """ 85 | Downloads from howjsay.com directly to an MP3. 86 | """ 87 | 88 | assert options['voice'] == 'en', "Only English is supported" 89 | 90 | if len(text) > 100: 91 | raise IOError("Input text is too long for Howjsay") 92 | 93 | from urllib.parse import quote 94 | 95 | try: 96 | self.net_download( 97 | path, 98 | 'http://www.howjsay.com/mp3/' + quote(text) + '.mp3', 99 | require=dict(mime='audio/mpeg', size=512), 100 | ) 101 | 102 | except (ValueError, IOError) as error: 103 | if getattr(error, 'code', None) == 404 or \ 104 | getattr(error, 'got_mime', None) == 'text/html': 105 | raise IOError( 106 | "Howjsay does not have recorded audio for this phrase. " 107 | "While most words have recordings, most phrases do not." 108 | if text.count(' ') 109 | else "Howjsay does not have recorded audio for this word." 110 | ) 111 | else: 112 | raise 113 | -------------------------------------------------------------------------------- /awesometts/service/imtranslator.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for ImTranslator's text-to-speech portal 21 | """ 22 | 23 | import re 24 | from socket import error as SocketError # non-caching error class 25 | 26 | from .base import Service 27 | from .common import Trait 28 | 29 | __all__ = ['ImTranslator'] 30 | 31 | 32 | URL = 'http://imtranslator.net/translate-and-speak/sockets/tts.asp' 33 | 34 | 35 | class ImTranslator(Service): 36 | """ 37 | Provides a Service-compliant implementation for ImTranslator. 38 | """ 39 | 40 | __slots__ = [] 41 | 42 | NAME = "ImTranslator" 43 | 44 | TRAITS = [Trait.INTERNET, Trait.TRANSCODING] 45 | 46 | _VOICES = [('Stefan', 'de', 'male'), ('VW Paul', 'en', 'male'), 47 | ('VW Kate', 'en', 'female'), ('Jorge', 'es', 'male'), 48 | ('Florence', 'fr', 'female'), ('Matteo', 'it', 'male'), 49 | ('VW Misaki', 'ja', 'female'), ('VW Yumi', 'ko', 'female'), 50 | ('Gabriela', 'pt', 'female'), ('Olga', 'ru', 'female'), 51 | ('VW Lily', 'zh', 'female')] 52 | 53 | _RE_SWF = re.compile(r'https?:[\w:/\.]+\.swf\?\w+=\w+', re.IGNORECASE) 54 | 55 | def __init__(self, *args, **kwargs): 56 | if self.IS_MACOSX: 57 | raise EnvironmentError( 58 | "ImTranslator cannot be used on Mac OS X due to mplayer " 59 | "crashes while dumping the audio. If you are able to fix " 60 | "this, please send a pull request." 61 | ) 62 | 63 | super(ImTranslator, self).__init__(*args, **kwargs) 64 | 65 | def desc(self): 66 | """ 67 | Returns a short, static description. 68 | """ 69 | 70 | return "ImTranslator text-to-speech web portal (%d voices)" % \ 71 | len(self._VOICES) 72 | 73 | def options(self): 74 | """ 75 | Provides access to voice and speed. 76 | """ 77 | 78 | voice_lookup = dict([ 79 | # language codes with full genders 80 | (self.normalize(code + gender), name) 81 | for name, code, gender in self._VOICES 82 | ] + [ 83 | # language codes with first character of genders 84 | (self.normalize(code + gender[0]), name) 85 | for name, code, gender in self._VOICES 86 | ] + [ 87 | # bare language codes 88 | (self.normalize(code), name) 89 | for name, code, gender in self._VOICES 90 | ] + [ 91 | # official voice names 92 | (self.normalize(name), name) 93 | for name, code, gender in self._VOICES 94 | ]) 95 | 96 | def transform_voice(value): 97 | """Normalize and attempt to convert to official name.""" 98 | 99 | normalized = self.normalize(value) 100 | if normalized in voice_lookup: 101 | return voice_lookup[normalized] 102 | 103 | # if input is more than two characters, maybe the user was trying 104 | # a country-specific code (e.g. en-US); chop it off and try again 105 | if len(normalized) > 2: 106 | normalized = normalized[0:2] 107 | if normalized in voice_lookup: 108 | return voice_lookup[normalized] 109 | 110 | return value 111 | 112 | def transform_speed(value): 113 | """Return the speed value closest to one of the user's.""" 114 | value = float(value) 115 | return min([10, 6, 3, 0, -3, -6, -10], 116 | key=lambda i: abs(i - value)) 117 | 118 | return [ 119 | dict( 120 | key='voice', 121 | label="Voice", 122 | values=[ 123 | (name, "%s (%s %s)" % (name, gender, code)) 124 | for name, code, gender in self._VOICES 125 | ], 126 | transform=transform_voice, 127 | ), 128 | 129 | dict( 130 | key='speed', 131 | label="Speed", 132 | values=[(10, "fastest"), (6, "faster"), (3, "fast"), 133 | (0, "normal"), 134 | (-3, "slow"), (-6, "slower"), (-10, "slowest")], 135 | transform=transform_speed, 136 | default=0, 137 | ), 138 | ] 139 | 140 | def run(self, text, options, path): 141 | """ 142 | Sends the TTS request to ImTranslator, captures the audio from 143 | the returned SWF, and transcodes to MP3. 144 | 145 | Because ImTranslator sometimes raises various errors, both steps 146 | of this (i.e. downloading the page and dumping the audio) may be 147 | retried up to three times. 148 | """ 149 | 150 | output_wavs = [] 151 | output_mp3s = [] 152 | require = dict(size_in=4096) 153 | 154 | logger = self._logger 155 | 156 | try: 157 | for subtext in self.util_split(text, 400): 158 | for i in range(1, 4): 159 | try: 160 | logger.info("ImTranslator net_stream: attempt %d", i) 161 | result = self.net_stream( 162 | (URL, 163 | dict(text=subtext, vc=options['voice'], 164 | speed=options['speed'], FA=1)), 165 | require=dict(mime='text/html', size=256), 166 | method='POST', 167 | custom_headers={'Referer': URL} 168 | ).decode() 169 | 170 | result = self._RE_SWF.search(result) 171 | if not result or not result.group(): 172 | raise EnvironmentError('500b', "cannot find SWF" 173 | "path in payload") 174 | result = result.group() 175 | except (EnvironmentError, IOError) as error: 176 | if getattr(error, 'code', None) == 500: 177 | logger.warn("ImTranslator net_stream: got 500") 178 | elif getattr(error, 'errno', None) == '500b': 179 | logger.warn("ImTranslator net_stream: no SWF path") 180 | elif 'timed out' in format(error): 181 | logger.warn("ImTranslator net_stream: timeout") 182 | else: 183 | logger.error("ImTranslator net_stream: %s", error) 184 | raise 185 | else: 186 | logger.info("ImTranslator net_stream: success") 187 | break 188 | else: 189 | logger.error("ImTranslator net_stream: exhausted") 190 | raise SocketError("unable to fetch page from ImTranslator " 191 | "even after multiple attempts") 192 | 193 | output_wav = self.path_temp('wav') 194 | output_wavs.append(output_wav) 195 | 196 | for i in range(1, 4): 197 | try: 198 | logger.info("ImTranslator net_dump: attempt %d", i) 199 | self.net_dump(output_wav, result) 200 | except RuntimeError: 201 | logger.warn("ImTranslator net_dump: failure") 202 | else: 203 | logger.info("ImTranslator net_dump: success") 204 | break 205 | else: 206 | logger.error("ImTranslator net_dump: exhausted") 207 | raise SocketError("unable to dump audio from ImTranslator " 208 | "even after multiple attempts") 209 | 210 | if len(output_wavs) > 1: 211 | for output_wav in output_wavs: 212 | output_mp3 = self.path_temp('mp3') 213 | output_mp3s.append(output_mp3) 214 | self.cli_transcode(output_wav, output_mp3, require=require) 215 | 216 | self.util_merge(output_mp3s, path) 217 | 218 | else: 219 | self.cli_transcode(output_wavs[0], path, require=require) 220 | 221 | finally: 222 | self.path_unlink(output_wavs, output_mp3s) 223 | -------------------------------------------------------------------------------- /awesometts/service/ispeech.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for the iSpeech API 21 | """ 22 | 23 | from .base import Service 24 | 25 | __all__ = ['ISpeech'] 26 | 27 | 28 | VOICES = { 29 | 'auenglishfemale': ('en-AU', 'female'), 30 | 'brportuguesefemale': ('pr-BR', 'female'), 31 | 'caenglishfemale': ('en-CA', 'female'), 32 | 'cafrenchfemale': ('fr-CA', 'female'), 33 | 'cafrenchmale': ('fr-CA', 'male'), 34 | 'chchinesefemale': ('zh-CMN', 'female'), 35 | 'chchinesemale': ('zh-CMN', 'male'), 36 | 'eurcatalanfemale': ('ca', 'female'), 37 | 'eurczechfemale': ('cs', 'female'), 38 | 'eurdanishfemale': ('da', 'female'), 39 | 'eurdutchfemale': ('nl', 'female'), 40 | 'eurfinnishfemale': ('fi', 'female'), 41 | 'eurfrenchfemale': ('fr-FR', 'female'), 42 | 'eurfrenchmale': ('fr-FR', 'male'), 43 | 'eurgermanfemale': ('de', 'female'), 44 | 'eurgermanmale': ('de', 'male'), 45 | 'euritalianfemale': ('it', 'female'), 46 | 'euritalianmale': ('it', 'male'), 47 | 'eurnorwegianfemale': ('no', 'female'), 48 | 'eurpolishfemale': ('pl', 'female'), 49 | 'eurportuguesefemale': ('pt-PT', 'female'), 50 | 'eurportuguesemale': ('pt-PT', 'male'), 51 | 'eurspanishfemale': ('es-ES', 'female'), 52 | 'eurspanishmale': ('es-ES', 'male'), 53 | 'eurturkishfemale': ('tr', 'female'), 54 | 'eurturkishmale': ('tr', 'male'), 55 | 'hkchinesefemale': ('zh-YUE', 'female'), 56 | 'huhungarianfemale': ('hu', 'female'), 57 | 'jpjapanesefemale': ('jp', 'female'), 58 | 'jpjapanesemale': ('jp', 'male'), 59 | 'krkoreanfemale': ('ko', 'female'), 60 | 'krkoreanmale': ('ko', 'male'), 61 | 'rurussianfemale': ('ru', 'female'), 62 | 'rurussianmale': ('ru', 'male'), 63 | 'swswedishfemale': ('sv', 'female'), 64 | 'twchinesefemale': ('zh-TW', 'female'), 65 | 'ukenglishfemale': ('en-GB', 'female'), 66 | 'ukenglishmale': ('en-GB', 'male'), 67 | 'usenglishfemale': ('en-US', 'female'), 68 | 'usenglishmale': ('en-US', 'male'), 69 | 'usspanishfemale': ('es-US', 'female'), 70 | 'usspanishmale': ('es-US', 'male'), 71 | } 72 | 73 | 74 | class ISpeech(Service): 75 | """ 76 | Provides a Service-compliant implementation for iSpeech. 77 | """ 78 | 79 | __slots__ = [ 80 | ] 81 | 82 | NAME = "iSpeech" 83 | 84 | # Although iSpeech is an Internet service, we do not mark it with 85 | # Trait.INTERNET, as it is a paid-for-by-the-user API, and we do not want 86 | # to rate-limit it or trigger error caching behavior 87 | TRAITS = [] 88 | 89 | def desc(self): 90 | """Returns name with a voice count.""" 91 | 92 | return "iSpeech API (%d voices)" % len(VOICES) 93 | 94 | def extras(self): 95 | """The iSpeech API requires an API key.""" 96 | 97 | return [dict(key='key', label="API Key", required=True)] 98 | 99 | def options(self): 100 | """Provides access to voice only.""" 101 | 102 | voice_lookup = {self.normalize(api_name): api_name 103 | for api_name in VOICES.keys()} 104 | 105 | def transform_voice(user_value): 106 | """Fixes whitespace and casing only.""" 107 | normalized_value = self.normalize(user_value) 108 | return (voice_lookup[normalized_value] 109 | if normalized_value in voice_lookup else user_value) 110 | 111 | return [ 112 | dict(key='voice', 113 | label="Voice", 114 | values=[(api_name, f"{api_name} ({gender} {language})") 115 | for api_name, (language, gender) 116 | in sorted(VOICES.items(), 117 | key=lambda item: (item[1][0], 118 | item[1][1]))], 119 | transform=transform_voice), 120 | 121 | dict(key='speed', 122 | label="Speed", 123 | values=(-10, +10), 124 | transform=lambda i: min(max(-10, int(round(float(i)))), +10), 125 | default=0), 126 | 127 | dict(key='pitch', 128 | label="Pitch", 129 | values=(0, +200), 130 | transform=lambda i: min(max(0, int(round(float(i)))), +200), 131 | default=100), 132 | ] 133 | 134 | def run(self, text, options, path): 135 | """Downloads from iSpeech API directly to an MP3.""" 136 | 137 | try: 138 | self.net_download( 139 | path, 140 | [ 141 | ('http://api.ispeech.org/api/rest', 142 | dict(apikey=options['key'], action='convert', 143 | text=subtext, voice=options['voice'], 144 | speed=options['speed'], pitch=options['pitch'])) 145 | for subtext in self.util_split(text, 250) 146 | ], 147 | require=dict(mime='audio/mpeg', size=256), 148 | add_padding=True, 149 | ) 150 | except ValueError as error: 151 | try: 152 | from urllib.parse import parse_qs 153 | error = ValueError(parse_qs(error.payload)['message'][0]) 154 | except Exception: 155 | pass 156 | raise error 157 | 158 | self.net_reset() # no throttle; FIXME should be controlled by trait 159 | -------------------------------------------------------------------------------- /awesometts/service/naver.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """NAVER Translate""" 20 | 21 | from .base import Service 22 | from .common import Trait 23 | 24 | __all__ = ['Naver'] 25 | 26 | 27 | CNDIC_ENDPOINT = 'http://tts.cndic.naver.com/tts/mp3ttsV1.cgi' 28 | CNDIC_CONFIG = [ 29 | ('enc', 0), 30 | ('pitch', 100), 31 | ('speed', 80), 32 | ('text_fmt', 0), 33 | ('volume', 100), 34 | ('wrapper', 0), 35 | ] 36 | 37 | TRANSLATE_INIT = 'http://translate.naver.com/getVcode.dic' 38 | TRANSLATE_ENDPOINT = 'http://translate.naver.com/tts' 39 | TRANSLATE_CONFIG = [ 40 | ('from', 'translate'), 41 | ('service', 'translate'), 42 | ('speech_fmt', 'mp3'), 43 | ] 44 | 45 | VOICE_CODES = [ 46 | ('ko', ( 47 | "Korean", 48 | False, 49 | [ 50 | ('speaker', 'mijin'), 51 | ], 52 | )), 53 | 54 | ('en', ( 55 | "English", 56 | False, 57 | [ 58 | ('speaker', 'clara'), 59 | ], 60 | )), 61 | 62 | ('ja', ( 63 | "Japanese", 64 | False, 65 | [ 66 | ('speaker', 'yuri'), 67 | ('speed', 2), 68 | ], 69 | )), 70 | 71 | ('zh', ( 72 | "Chinese", 73 | True, 74 | [ 75 | ('spk_id', 250), 76 | ], 77 | )), 78 | ] 79 | 80 | VOICE_LOOKUP = dict(VOICE_CODES) 81 | 82 | 83 | def _quote_all(input_string, 84 | *args, **kwargs): # pylint:disable=unused-argument 85 | """NAVER Translate needs every character quoted.""" 86 | return ''.join('%%%x' % ord(char) for char in input_string) 87 | 88 | 89 | class Naver(Service): 90 | """Provides a Service implementation for NAVER Translate.""" 91 | 92 | __slots__ = [] 93 | 94 | NAME = "NAVER Translate" 95 | 96 | TRAITS = [Trait.INTERNET] 97 | 98 | def desc(self): 99 | """Returns a static description.""" 100 | 101 | return "NAVER Translate (%d voices)" % len(VOICE_CODES) 102 | 103 | def options(self): 104 | """Returns an option to select the voice.""" 105 | 106 | return [ 107 | dict( 108 | key='voice', 109 | label="Voice", 110 | values=[(key, description) 111 | for key, (description, _, _) in VOICE_CODES], 112 | transform=lambda str: self.normalize(str)[0:2], 113 | default='ko', 114 | ), 115 | ] 116 | 117 | def run(self, text, options, path): 118 | """Downloads from Internet directly to an MP3.""" 119 | 120 | _, is_cndic_api, config = VOICE_LOOKUP[options['voice']] 121 | 122 | if is_cndic_api: 123 | self.net_download( 124 | path, 125 | [ 126 | ( 127 | CNDIC_ENDPOINT, 128 | dict( 129 | CNDIC_CONFIG + 130 | config + 131 | [ 132 | ('text', subtext), 133 | ] 134 | ) 135 | ) 136 | for subtext in self.util_split(text, 250) 137 | ], 138 | require=dict(mime='audio/mpeg', size=256), 139 | custom_quoter=dict(text=_quote_all), 140 | ) 141 | 142 | else: 143 | def process_subtext(output_mp3, subtext): 144 | """Request a vcode and download the MP3.""" 145 | 146 | vcode = self.net_stream( 147 | (TRANSLATE_INIT, dict(text=subtext)), 148 | method='POST', 149 | ).decode() 150 | vcode = ''.join(char for char in vcode if char.isdigit()) 151 | 152 | self.net_download( 153 | output_mp3, 154 | ( 155 | TRANSLATE_ENDPOINT, 156 | dict( 157 | TRANSLATE_CONFIG + 158 | config + 159 | [ 160 | ('text', subtext), 161 | ('vcode', vcode), 162 | ] 163 | ), 164 | ), 165 | require=dict(mime='audio/mpeg', size=256), 166 | custom_quoter=dict(text=_quote_all), 167 | ) 168 | 169 | subtexts = self.util_split(text, 250) 170 | 171 | if len(subtexts) == 1: 172 | process_subtext(path, subtexts[0]) 173 | 174 | else: 175 | try: 176 | output_mp3s = [] 177 | 178 | for subtext in subtexts: 179 | output_mp3 = self.path_temp('mp3') 180 | output_mp3s.append(output_mp3) 181 | process_subtext(output_mp3, subtext) 182 | 183 | self.util_merge(output_mp3s, path) 184 | 185 | finally: 186 | self.path_unlink(output_mp3s) 187 | -------------------------------------------------------------------------------- /awesometts/service/neospeech.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for NeoSpeech's text-to-speech demo engine 21 | """ 22 | 23 | import json 24 | from threading import Lock 25 | 26 | from .base import Service 27 | from .common import Trait 28 | 29 | __all__ = ['NeoSpeech'] 30 | 31 | 32 | VOICES = [('en-GB', 'male', "Hugh", 33), ('en-GB', 'female', "Bridget", 4), 33 | ('en-US', 'male', "James", 10), ('en-US', 'male', "Paul", 1), 34 | ('en-US', 'female', "Ashley", 14), ('en-US', 'female', "Beth", 35), ('en-US', 'female', "Julie", 3), ('en-US', 'female', "Kate", 2), 35 | ('de', 'male', "Tim", 44), ('de', 'female', "Lena", 43), #Tim 44, Lena 43 36 | ('fr-EU', 'male', "Louis", 50), ('fr-EU', 'female', "Roxane", 49), #Louis 50, Roxane 49 37 | ('fr-CA', 'female', "Chloe", 13), ('fr-CA', 'male', "Leo", 34), #Leo 34 38 | ('es-EU', 'male', "Manuel", 46), ('es-EU', 'female', "Lola", 45), #Manuel 46, Lola 45 39 | ('es-MX', 'male', "Francisco", 31), ('es-MX', 'female', "Gloria", 32), ('es-MX', 'female', "Violeta", 5), 40 | ('it', 'male', "Roberto", 48), ('it', 'female', "Elisa", 47), #Roberto 48, Elisa 47 41 | ('pt-BR', 'male', "Rafael", 42), ('pt-BR', 'female', "Helena", 41), #Rafael 42, Helena 41 42 | ('ja', 'male', "Ryo", 28), ('ja', 'male', "Show", 8), ('ja', 'male', "Takeru", 30), 43 | ('ja', 'female', "Haruka", 26), ('ja', 'female', "Hikari", 29), 44 | ('ja', 'female', "Misaki", 9), ('ja', 'female', "Sayaka", 27), 45 | ('ko', 'male', "Jihun", 21), ('ko', 'male', "Junwoo", 6), 46 | ('ko', 'female', "Dayoung", 17), ('ko', 'female', "Hyeryun", 18), 47 | ('ko', 'female', "Hyuna", 19), ('ko', 'female', "Jimin", 20), 48 | ('ko', 'female', "Sena", 22), ('ko', 'female', "Yumi", 7), 49 | ('ko', 'female', "Yura", 23), ('zh', 'male', "Liang", 12), 50 | ('zh', 'male', "Qiang", 25), ('zh', 'female', "Hong", 24), ('zh', 'female', "Hui", 11), 51 | ('zh-TW', 'female', "Yafang", 36), #Yafang 36 52 | ('zh-CA', 'male', "Kano", 37), ('zh-CA', 'female', "Kayan", 38), #Kano 37, Kayan 38 53 | ('th', 'male', "Sarawut", 39), ('th', 'female', "Somsi", 40), #Sarawut 39, Somsi 40 54 | ('aa', 'female', "Test51", 51), ('aa', 'male', "Test52", 52)] 55 | 56 | MAP = {name: api_id for language, gender, name, api_id in VOICES} 57 | 58 | 59 | BASE_URL = 'http://neospeech.com' 60 | 61 | DEMO_URL = BASE_URL + '/service/demo' 62 | 63 | REQUIRE_MP3 = dict(mime='audio/mpeg', size=256) 64 | 65 | 66 | class NeoSpeech(Service): 67 | """ 68 | Provides a Service-compliant implementation for NeoSpeech. 69 | """ 70 | 71 | __slots__ = [ 72 | '_lock', # download URL is tied to cookie; force serial runs 73 | '_cookies', # used for all NeoSpeech requests in this Anki session 74 | '_last_phrase', # last subtext we sent to NeoSpeech 75 | '_last_stream', # last download we got from NeoSpeech 76 | ] 77 | 78 | NAME = "NeoSpeech" 79 | 80 | TRAITS = [Trait.INTERNET] 81 | 82 | def __init__(self, *args, **kwargs): 83 | self._lock = Lock() 84 | self._cookies = None 85 | self._last_phrase = None 86 | self._last_stream = None 87 | super(NeoSpeech, self).__init__(*args, **kwargs) 88 | 89 | def desc(self): 90 | """Returns name with a voice count.""" 91 | 92 | return "NeoSpeech Demo (%d voices)" % len(VOICES) 93 | 94 | def options(self): 95 | """Provides access to voice only.""" 96 | 97 | voice_lookup = {self.normalize(name): name 98 | for language, gender, name, api_id in VOICES} 99 | 100 | def transform_voice(value): 101 | """Fixes whitespace and casing errors only.""" 102 | normal = self.normalize(value) 103 | return voice_lookup[normal] if normal in voice_lookup else value 104 | 105 | return [dict(key='voice', 106 | label="Voice", 107 | values=[(name, "%s (%s %s)" % (name, gender, language)) 108 | for language, gender, name, _ in VOICES], 109 | transform=transform_voice)] 110 | 111 | def run(self, text, options, path): 112 | """Requests MP3 URLs and then downloads them.""" 113 | 114 | with self._lock: 115 | if not self._cookies: 116 | headers = self.net_headers(BASE_URL) 117 | self._cookies = ';'.join( 118 | cookie.split(';')[0] 119 | for cookie in headers['Set-Cookie'].split(',') 120 | ) 121 | self._logger.debug("NeoSpeech cookies are %s", self._cookies) 122 | headers = {'Cookie': self._cookies} 123 | 124 | voice_id = MAP[options['voice']] 125 | 126 | def fetch_piece(subtext, subpath): 127 | """Fetch given phrase from the API to the given path.""" 128 | 129 | payload = self.net_stream((DEMO_URL, dict(content=subtext, 130 | voiceId=voice_id)), 131 | custom_headers=headers) 132 | 133 | try: 134 | data = json.loads(payload) 135 | except ValueError: 136 | raise ValueError("Unable to interpret the response from " 137 | "the NeoSpeech service") 138 | 139 | try: 140 | url = data['audioUrl'] 141 | except KeyError: 142 | raise KeyError("Cannot find the audio URL in the response " 143 | "from the NeoSpeech service") 144 | assert isinstance(url, str) and len(url) > 2 and \ 145 | url[0] == '/' and url[1].isalnum(), \ 146 | "The audio URL from NeoSpeech does not seem to be valid" 147 | 148 | mp3_stream = self.net_stream(BASE_URL + url, 149 | require=REQUIRE_MP3, 150 | custom_headers=headers) 151 | if self._last_phrase != subtext and \ 152 | self._last_stream == mp3_stream: 153 | raise IOError("NeoSpeech seems to be returning the same " 154 | "MP3 file twice in a row; it may be having " 155 | "service problems.") 156 | self._last_phrase = subtext 157 | self._last_stream = mp3_stream 158 | with open(subpath, 'wb') as mp3_file: 159 | mp3_file.write(mp3_stream) 160 | 161 | subtexts = self.util_split(text, 200) # see `maxlength` on site 162 | if len(subtexts) == 1: 163 | fetch_piece(subtexts[0], path) 164 | else: 165 | intermediate_mp3s = [] 166 | try: 167 | for subtext in subtexts: 168 | intermediate_mp3 = self.path_temp('mp3') 169 | intermediate_mp3s.append(intermediate_mp3) 170 | fetch_piece(subtext, intermediate_mp3) 171 | self.util_merge(intermediate_mp3s, path) 172 | finally: 173 | self.path_unlink(intermediate_mp3s) 174 | -------------------------------------------------------------------------------- /awesometts/service/oxford.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Oxford Dictionary 21 | """ 22 | 23 | import re 24 | from html.parser import HTMLParser 25 | 26 | from .base import Service 27 | from .common import Trait 28 | 29 | __all__ = ['Oxford'] 30 | 31 | 32 | RE_WHITESPACE = re.compile(r'[-\0\s_]+', re.UNICODE) 33 | 34 | # n.b. The OED has words with periods (e.g. "Capt."), so we preserve those, 35 | # but other punctuation can generally be dropped. The re.UNICODE flag here is 36 | # important so that accented characters are not filtered out. 37 | RE_DISCARD = re.compile(r'[^-.\s\w]+', re.UNICODE) 38 | 39 | 40 | class OxfordLister(HTMLParser): 41 | """Accumulate all found MP3s into `sounds` member.""" 42 | 43 | def reset(self): 44 | HTMLParser.reset(self) 45 | self.sounds = [] 46 | self.prev_tag = "" 47 | 48 | def handle_starttag(self, tag, attrs): 49 | if tag == "audio" and self.prev_tag == "a": 50 | snd = [v for k, v in attrs if k == "src"] 51 | if snd: 52 | self.sounds.extend(snd) 53 | if tag == "a" and ("class", "speaker") in attrs: 54 | self.prev_tag = tag 55 | 56 | 57 | class Oxford(Service): 58 | """ 59 | Provides a Service-compliant implementation for Oxford Dictionary. 60 | """ 61 | 62 | __slots__ = [] 63 | 64 | NAME = "Oxford Dictionary" 65 | 66 | TRAITS = [Trait.INTERNET, Trait.DICTIONARY] 67 | 68 | def desc(self): 69 | """ 70 | Returns a short, static description. 71 | """ 72 | 73 | return "Oxford Dictionary (British and American English); " \ 74 | "dictionary words only, with (optional) fuzzy matching" 75 | 76 | def options(self): 77 | """ 78 | Provides access to voice and fuzzy matching switch. 79 | """ 80 | 81 | voice_lookup = dict([ 82 | # aliases for English, American 83 | (self.normalize(alias), 'en-US') 84 | for alias in ['American', 'American English', 'English, American', 85 | 'US'] 86 | ] + [ 87 | # aliases for English, British ("default" for the OED) 88 | (self.normalize(alias), 'en-GB') 89 | for alias in ['British', 'British English', 'English, British', 90 | 'English', 'en', 'en-EU', 'en-UK', 'EU', 'GB', 'UK'] 91 | ]) 92 | 93 | def transform_voice(value): 94 | """Normalize and attempt to convert to official code.""" 95 | normalized = self.normalize(value) 96 | if normalized in voice_lookup: 97 | return voice_lookup[normalized] 98 | return value 99 | 100 | return [ 101 | dict( 102 | key='voice', 103 | label="Voice", 104 | values=[('en-US', "English, American (en-US)"), 105 | ('en-GB', "English, British (en-GB)")], 106 | default='en-GB', 107 | transform=transform_voice, 108 | ), 109 | dict( 110 | key='fuzzy', 111 | label="Fuzzy matching", 112 | values=[(True, 'Enabled'), (False, 'Disabled')], 113 | default=True, 114 | transform=bool 115 | ) 116 | ] 117 | 118 | def modify(self, text): 119 | """ 120 | OED generally represents words with spaces using a dash between 121 | the words. Case usually doesn't matter, but sometimes it does, 122 | so we do not normalize it (e.g. "United-Kingdom" works but 123 | "united-kingdom" does not). 124 | """ 125 | 126 | return RE_WHITESPACE.sub('-', RE_DISCARD.sub('', text)).strip('-') 127 | 128 | def run(self, text, options, path): 129 | """ 130 | Download web page for given word 131 | Then extract mp3 path and download it 132 | """ 133 | 134 | if len(text) > 100: 135 | raise IOError("Input text is too long for the Oxford Dictionary") 136 | 137 | from urllib.parse import quote 138 | dict_url = 'https://en.oxforddictionaries.com/definition/%s%s' % ( 139 | 'us/' if options['voice'] == 'en-US' else '', 140 | quote(text.encode('utf-8')) 141 | ) 142 | 143 | try: 144 | html_payload = self.net_stream(dict_url, allow_redirects=options['fuzzy']) 145 | except IOError as io_error: 146 | if getattr(io_error, 'code', None) == 404: 147 | raise IOError( 148 | "The Oxford Dictionary does not recognize this phrase. " 149 | "While most single words are recognized, many multi-word " 150 | "phrases are not." 151 | if text.count('-') 152 | else "The Oxford Dictionary does not recognize this word." 153 | ) 154 | else: 155 | raise 156 | except ValueError as error: 157 | if str(error) == "Request has been redirected": 158 | raise IOError( 159 | "The Oxford Dictionary has no exact match for your input. " 160 | "You can enable fuzzy-matching in options." 161 | ) 162 | raise error 163 | 164 | parser = OxfordLister() 165 | parser.feed(html_payload.decode('utf-8')) 166 | parser.close() 167 | 168 | if len(parser.sounds) > 0: 169 | sound_url = parser.sounds[0] 170 | 171 | self.net_download( 172 | path, 173 | sound_url, 174 | require=dict(mime='audio/mpeg', size=1024), 175 | ) 176 | else: 177 | raise IOError( 178 | "The Oxford Dictionary does not currently seem to be " 179 | "advertising American English pronunciation. You may want to " 180 | "consider either using a different service or switching to " 181 | "British English." 182 | if options['voice'] == 'en-US' 183 | 184 | else 185 | "The Oxford Dictionary has no recorded audio for your input." 186 | ) 187 | -------------------------------------------------------------------------------- /awesometts/service/pico2wave.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for SVOX Pico TTS 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Pico2Wave'] 27 | 28 | 29 | class Pico2Wave(Service): 30 | """ 31 | Provides a Service-compliant implementation for SVOX Pico TTS. 32 | """ 33 | 34 | __slots__ = [ 35 | '_binary', # path to the pico2wave binary 36 | '_voice_list', # list of installed voices as a list of tuples 37 | ] 38 | 39 | NAME = "SVOX Pico" 40 | 41 | TRAITS = [Trait.TRANSCODING] 42 | 43 | def __init__(self, *args, **kwargs): 44 | """ 45 | Attempts to read the list of voices from stderr when triggering 46 | a usage error with `pico2wave --lang X --wave X X`. 47 | """ 48 | 49 | if self.IS_WINDOWS: 50 | raise EnvironmentError( 51 | "SVOX Pico cannot be used on Windows because unicode text " 52 | "cannot be passed to the CLI via the subprocess module in " 53 | "in Python 2 and pico2wave offers no input alternative" 54 | ) 55 | 56 | super(Pico2Wave, self).__init__(*args, **kwargs) 57 | 58 | import re 59 | re_voice = re.compile(r'^[a-z]{2}-[A-Z]{2}$') 60 | 61 | for binary in ['pico2wave', 'lt-pico2wave']: 62 | try: 63 | self._voice_list = sorted({ 64 | (line, line) 65 | for line in self.cli_output_error( 66 | binary, 67 | '--lang', 'x', 68 | '--wave', 'x', 69 | 'x', 70 | ) 71 | if re_voice.match(line) 72 | }) 73 | 74 | if self._voice_list: 75 | self._binary = binary 76 | break 77 | 78 | except Exception: 79 | continue 80 | 81 | else: 82 | raise EnvironmentError("No usable pico2wave call was found") 83 | 84 | def desc(self): 85 | """ 86 | Returns the name of the binary in-use and how many voices it 87 | reported. 88 | """ 89 | 90 | return "%s (%d voices)" % (self._binary, len(self._voice_list)) 91 | 92 | def options(self): 93 | """ 94 | Provides access to voice only. 95 | """ 96 | 97 | voice_lookup = dict([ 98 | # two-letter language codes (for countries with multiple variants, 99 | # last one alphabetically wins, e.g. en maps to en-US, not en-GB) 100 | (self.normalize(voice[0][0:2]), voice[0]) 101 | for voice in self._voice_list 102 | ] + [ 103 | # official language codes 104 | (self.normalize(voice[0]), voice[0]) 105 | for voice in self._voice_list 106 | ]) 107 | 108 | def transform_voice(value): 109 | """Normalize and attempt to convert to official voice.""" 110 | 111 | normalized = self.normalize(value) 112 | return voice_lookup[normalized] if normalized in voice_lookup \ 113 | else value 114 | 115 | return [ 116 | dict( 117 | key='voice', 118 | label="Voice", 119 | values=self._voice_list, 120 | transform=transform_voice, 121 | ), 122 | ] 123 | 124 | def run(self, text, options, path): 125 | """ 126 | Writes a temporary wave file and then transcodes to MP3. 127 | 128 | Note that unlike other services (e.g. eSpeak), we do not attempt 129 | to workaround the unicode problem on Windows because `pico2wave` 130 | has no alternate input method for reading from a file. 131 | """ 132 | 133 | output_wav = self.path_temp('wav') 134 | 135 | try: 136 | self.cli_call( 137 | self._binary, 138 | '--lang', options['voice'], 139 | '--wave', output_wav, 140 | '--', text, 141 | ) 142 | 143 | self.cli_transcode( 144 | output_wav, 145 | path, 146 | require=dict( 147 | size_in=4096, 148 | ), 149 | add_padding=True, 150 | ) 151 | 152 | finally: 153 | self.path_unlink(output_wav) 154 | -------------------------------------------------------------------------------- /awesometts/service/rhvoice.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for RHVoice 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['RHVoice'] 27 | 28 | 29 | VOICES_DIRS = (prefix + '/share/RHVoice/voices' 30 | for prefix in ['~', '~/usr', '/usr/local', '/usr']) 31 | INFO_FILE = 'voice.info' 32 | 33 | NAME_KEY = 'name' 34 | LANGUAGE_KEY = 'language' 35 | GENDER_KEY = 'gender' 36 | 37 | PERCENT_VALUES = (-100, +100, "%") 38 | 39 | 40 | def decimalize(percentage_value): 41 | """Given an integer within [-100, 100], return a decimal.""" 42 | return round(percentage_value / 100.0, 2) 43 | 44 | 45 | class RHVoice(Service): 46 | """Provides a Service-compliant implementation for RHVoice.""" 47 | 48 | __slots__ = [ 49 | '_voice_list', # sorted list of (voice value, human label) tuples 50 | '_backgrounded', # True if AwesomeTTS needed to start the service 51 | ] 52 | 53 | NAME = "RHVoice" 54 | 55 | TRAITS = [Trait.TRANSCODING] 56 | 57 | def __init__(self, *args, **kwargs): 58 | """ 59 | Searches the RHVoice voice path for usable voices and populates 60 | the voices list. 61 | """ 62 | 63 | if not self.IS_LINUX: 64 | raise EnvironmentError("AwesomeTTS only knows how to work w/ the " 65 | "Linux version of RHVoice at this time.") 66 | 67 | super(RHVoice, self).__init__(*args, **kwargs) 68 | 69 | def get_voice_info(voice_file): 70 | """Given a voice.info path, return a dict of voice info.""" 71 | 72 | try: 73 | lookup = {} 74 | with open(voice_file) as voice_info: 75 | for line in voice_info: 76 | tokens = line.split('=', 1) 77 | lookup[tokens[0]] = tokens[1].strip() 78 | return lookup 79 | except Exception: 80 | return {} 81 | 82 | def get_voices_from(path): 83 | """Return a list of voices at the given path, if any.""" 84 | 85 | from os import listdir 86 | from os.path import expanduser, join, isdir, isfile 87 | 88 | path = expanduser(path) 89 | self._logger.debug("Searching %s for voices", path) 90 | 91 | result = [ 92 | ( 93 | voice_name, 94 | "%s (%s, %s)" % ( 95 | voice_info.get(NAME_KEY, voice_name), 96 | voice_info.get(LANGUAGE_KEY, "no language"), 97 | voice_info.get(GENDER_KEY, "no gender"), 98 | ), 99 | ) 100 | for voice_name, voice_info in sorted( 101 | ( 102 | (voice_name, get_voice_info(voice_file)) 103 | for (voice_name, voice_file) in ( 104 | (voice_name, voice_file) 105 | for (voice_name, voice_file) 106 | in ( 107 | (voice_name, join(voice_dir, INFO_FILE)) 108 | for (voice_name, voice_dir) 109 | in ( 110 | (voice_name, join(path, voice_name)) 111 | for voice_name in listdir(path) 112 | ) 113 | if isdir(voice_dir) 114 | ) 115 | if isfile(voice_file) 116 | ) 117 | ), 118 | key=lambda voice_name_voice_info: ( 119 | voice_name_voice_info[1].get(LANGUAGE_KEY), 120 | voice_name_voice_info[1].get(NAME_KEY, voice_name_voice_info[0]), 121 | ) 122 | ) 123 | ] 124 | 125 | if not result: 126 | raise EnvironmentError 127 | 128 | return result 129 | 130 | for path in VOICES_DIRS: 131 | try: 132 | self._voice_list = get_voices_from(path) 133 | break 134 | except Exception: 135 | continue 136 | else: 137 | raise EnvironmentError("No usable voices could be found") 138 | 139 | dbus_check = ''.join(self.cli_output_error('RHVoice-client', 140 | '-s', '__awesometts_check')) 141 | if 'ServiceUnknown' in dbus_check and 'RHVoice' in dbus_check: 142 | self.cli_background('RHVoice-service') 143 | self._backgrounded = True 144 | else: 145 | self._backgrounded = False 146 | 147 | def desc(self): 148 | """Return short description with voice count.""" 149 | 150 | return "RHVoice synthesizer (%d voices), %s" % ( 151 | len(self._voice_list), 152 | "service started by AwesomeTTS" if self._backgrounded 153 | else "provided by host system" 154 | ) 155 | 156 | def options(self): 157 | """Provides access to voice, speed, pitch, and volume.""" 158 | 159 | voice_lookup = {self.normalize(voice[0]): voice[0] 160 | for voice in self._voice_list} 161 | 162 | def transform_voice(value): 163 | """Normalize and attempt to convert to official voice.""" 164 | normalized = self.normalize(value) 165 | return (voice_lookup[normalized] if normalized in voice_lookup 166 | else value) 167 | 168 | def transform_percent(user_input): 169 | """Given some user input, return a integer within [-100, 100].""" 170 | return min(max(-100, int(round(float(user_input)))), +100) 171 | 172 | return [ 173 | dict(key='voice', label="Voice", values=self._voice_list, 174 | transform=transform_voice), 175 | dict(key='speed', label="Speed", values=PERCENT_VALUES, 176 | transform=transform_percent, default=0), 177 | dict(key='pitch', label="Pitch", values=PERCENT_VALUES, 178 | transform=transform_percent, default=0), 179 | dict(key='volume', label="Volume", values=PERCENT_VALUES, 180 | transform=transform_percent, default=0), 181 | ] 182 | 183 | def run(self, text, options, path): 184 | """ 185 | Saves the incoming text into a file, and pipes it through 186 | RHVoice-client and back out to a temporary wave file. If 187 | successful, the temporary wave file will be transcoded to an MP3 188 | for consumption by AwesomeTTS. 189 | """ 190 | 191 | try: 192 | input_txt = self.path_input(text) 193 | output_wav = self.path_temp('wav') 194 | 195 | self.cli_pipe( 196 | ['RHVoice-client', 197 | '-s', options['voice'], 198 | '-r', decimalize(options['speed']), 199 | '-p', decimalize(options['pitch']), 200 | '-v', decimalize(options['volume'])], 201 | input_path=input_txt, 202 | output_path=output_wav, 203 | ) 204 | 205 | self.cli_transcode(output_wav, 206 | path, 207 | require=dict(size_in=4096)) 208 | 209 | finally: 210 | self.path_unlink(input_txt, output_wav) 211 | -------------------------------------------------------------------------------- /awesometts/service/sapi5com.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for SAPI 5 on the Windows platform via win32com 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | from .sapi5js import LANGUAGE_CODES 27 | 28 | __all__ = ['SAPI5COM'] 29 | 30 | 31 | class SAPI5COM(Service): 32 | """ 33 | Provides a Service-compliant implementation for SAPI 5 via win32com. 34 | """ 35 | 36 | __slots__ = [ 37 | '_client', # reference to the win32com.client module 38 | '_pythoncom', # reference to the pythoncom module 39 | '_voice_map', # dict of voice names to their SAPI objects 40 | ] 41 | 42 | NAME = "Microsoft Speech API COM" 43 | 44 | TRAITS = [Trait.TRANSCODING] 45 | 46 | def __init__(self, *args, **kwargs): 47 | """ 48 | Attempts to retrieve list of voices from the SAPI.SpVoice API. 49 | 50 | However, if not running on Windows, no environment inspection is 51 | attempted and an exception is immediately raised. 52 | """ 53 | 54 | if not self.IS_WINDOWS: 55 | raise EnvironmentError("SAPI 5 is only available on Windows") 56 | 57 | super(SAPI5COM, self).__init__(*args, **kwargs) 58 | 59 | # win32com and pythoncom are Windows only, pylint:disable=import-error 60 | 61 | try: 62 | import win32com.client 63 | except IOError: # some Anki packages have an unwritable cache path 64 | self._logger.warn("win32com.client import failed; trying again " 65 | "with alternate __gen_path__ set") 66 | import win32com 67 | import os.path 68 | import tempfile 69 | win32com.__gen_path__ = os.path.join(tempfile.gettempdir(), 70 | 'gen_py') 71 | import win32com.client 72 | self._client = win32com.client 73 | 74 | import pythoncom 75 | self._pythoncom = pythoncom 76 | 77 | # pylint:enable=import-error 78 | 79 | voices = self._client.Dispatch('SAPI.SpVoice').getVoices() 80 | self._voice_map = { 81 | voice.getAttribute('name'): voice 82 | for voice in [voices.item(i) for i in range(voices.count)] 83 | } 84 | 85 | if not self._voice_map: 86 | raise EnvironmentError("No voices returned by SAPI 5") 87 | 88 | def desc(self): 89 | """ 90 | Returns a short, static description. 91 | """ 92 | 93 | count = len(self._voice_map) 94 | return ("SAPI 5.0 via win32com (%d %s)" % 95 | (count, "voice" if count == 1 else "voices")) 96 | 97 | def options(self): 98 | """ 99 | Provides access to voice, speed, and volume. 100 | """ 101 | 102 | voice_lookup = dict([ 103 | # normalized with characters w/ diacritics stripped 104 | (self.normalize(voice[0]), voice[0]) 105 | for voice in self._voice_map.keys() 106 | ] + [ 107 | # normalized with diacritics converted 108 | (self.normalize(self.util_approx(voice[0])), voice[0]) 109 | for voice in self._voice_map.keys() 110 | ]) 111 | 112 | def transform_voice(value): 113 | """Normalize and attempt to convert to official voice.""" 114 | 115 | normalized = self.normalize(value) 116 | 117 | return ( 118 | voice_lookup[normalized] if normalized in voice_lookup 119 | else value 120 | ) 121 | 122 | def get_voice_desc(name): 123 | try: 124 | lang = str(self._voice_map[name].getAttribute('language')).lower().strip() 125 | return '%s (%s)' % (name, LANGUAGE_CODES.get(lang, lang)) 126 | except: 127 | return name 128 | 129 | return [ 130 | dict( 131 | key='voice', 132 | label="Voice", 133 | values=[(voice, get_voice_desc(voice)) 134 | for voice in sorted(self._voice_map.keys())], 135 | transform=transform_voice, 136 | ), 137 | 138 | dict( 139 | key='speed', 140 | label="Speed", 141 | values=(-10, 10), 142 | transform=int, 143 | default=0, 144 | ), 145 | 146 | dict( 147 | key='volume', 148 | label="Volume", 149 | values=(1, 100, "%"), 150 | transform=int, 151 | default=100, 152 | ), 153 | 154 | dict( 155 | key='quality', 156 | label="Quality", 157 | values=[ 158 | (4, "8 kHz, 8-bit, Mono"), 159 | (5, "8 kHz, 8-bit, Stereo"), 160 | (6, "8 kHz, 16-bit, Mono"), 161 | (7, "8 kHz, 16-bit, Stereo"), 162 | (8, "11 kHz, 8-bit, Mono"), 163 | (9, "11 kHz, 8-bit, Stereo"), 164 | (10, "11 kHz, 16-bit, Mono"), 165 | (11, "11 kHz, 16-bit, Stereo"), 166 | (12, "12 kHz, 8-bit, Mono"), 167 | (13, "12 kHz, 8-bit, Stereo"), 168 | (14, "12 kHz, 16-bit, Mono"), 169 | (15, "12 kHz, 16-bit, Stereo"), 170 | (16, "16 kHz, 8-bit, Mono"), 171 | (17, "16 kHz, 8-bit, Stereo"), 172 | (18, "16 kHz, 16-bit, Mono"), 173 | (19, "16 kHz, 16-bit, Stereo"), 174 | (20, "22 kHz, 8-bit, Mono"), 175 | (21, "22 kHz, 8-bit, Stereo"), 176 | (22, "22 kHz, 16-bit, Mono"), 177 | (23, "22 kHz, 16-bit, Stereo"), 178 | (24, "24 kHz, 8-bit, Mono"), 179 | (25, "24 kHz, 8-bit, Stereo"), 180 | (26, "24 kHz, 16-bit, Mono"), 181 | (27, "24 kHz, 16-bit, Stereo"), 182 | (28, "32 kHz, 8-bit, Mono"), 183 | (29, "32 kHz, 8-bit, Stereo"), 184 | (30, "32 kHz, 16-bit, Mono"), 185 | (31, "32 kHz, 16-bit, Stereo"), 186 | (32, "44 kHz, 8-bit, Mono"), 187 | (33, "44 kHz, 8-bit, Stereo"), 188 | (34, "44 kHz, 16-bit, Mono"), 189 | (35, "44 kHz, 16-bit, Stereo"), 190 | (36, "48 kHz, 8-bit, Mono"), 191 | (37, "48 kHz, 8-bit, Stereo"), 192 | (38, "48 kHz, 16-bit, Mono"), 193 | (39, "48 kHz, 16-bit, Stereo"), 194 | ], 195 | transform=int, 196 | default=39, 197 | ), 198 | 199 | dict( 200 | key='xml', 201 | label="XML", 202 | values=[ 203 | (0, "automatic"), 204 | (8, "always parse"), 205 | (16, "pass through"), 206 | ], 207 | transform=int, 208 | default=0, 209 | ), 210 | ] 211 | 212 | def run(self, text, options, path): 213 | """ 214 | Writes a temporary wave file, and then transcodes to MP3. 215 | """ 216 | 217 | output_wav = self.path_temp('wav') 218 | self._pythoncom.CoInitializeEx(self._pythoncom.COINIT_MULTITHREADED) 219 | 220 | try: 221 | stream = self._client.Dispatch('SAPI.SpFileStream') 222 | stream.Format.Type = options['quality'] 223 | stream.open(output_wav, 3) # 3=SSFMCreateForWrite 224 | 225 | try: 226 | speech = self._client.Dispatch('SAPI.SpVoice') 227 | speech.AudioOutputStream = stream 228 | speech.Rate = options['speed'] 229 | speech.Voice = self._voice_map[options['voice']] 230 | speech.Volume = options['volume'] 231 | 232 | if options['xml']: 233 | speech.speak(text, options['xml']) 234 | else: 235 | speech.speak(text) 236 | finally: 237 | stream.close() 238 | 239 | self.cli_transcode( 240 | output_wav, 241 | path, 242 | require=dict( 243 | size_in=4096, 244 | ), 245 | ) 246 | 247 | finally: 248 | self._pythoncom.CoUninitialize() 249 | self.path_unlink(output_wav) 250 | -------------------------------------------------------------------------------- /awesometts/service/sapi5js.js: -------------------------------------------------------------------------------- 1 | /* 2 | * AwesomeTTS text-to-speech add-on for Anki 3 | * Copyright (C) 2010-Present Anki AwesomeTTS Development Team 4 | * 5 | * This program is free software: you can redistribute it and/or modify 6 | * it under the terms of the GNU General Public License as published by 7 | * the Free Software Foundation, either version 3 of the License, or 8 | * (at your option) any later version. 9 | * 10 | * This program is distributed in the hope that it will be useful, 11 | * but WITHOUT ANY WARRANTY; without even the implied warranty of 12 | * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 13 | * GNU General Public License for more details. 14 | * 15 | * You should have received a copy of the GNU General Public License 16 | * along with this program. If not, see . 17 | */ 18 | 19 | /** 20 | * Really simple JScript gateway for talking to the Microsoft Speech API. 21 | * 22 | * cscript sapi5js.js voice-list 23 | * cscript sapi5js.js speech-output 24 | * 25 | */ 26 | 27 | /*globals WScript*/ 28 | 29 | 30 | var argv = WScript.arguments; 31 | 32 | if (typeof argv !== 'object') { 33 | throw new Error("Unable to read from the arguments list"); 34 | } 35 | 36 | 37 | var argc = argv.count(); 38 | 39 | if (typeof argc !== 'number' || argc < 1) { 40 | throw new Error("Expecting the command to execute"); 41 | } 42 | 43 | 44 | var command = argv.item(0); 45 | var options = {}; 46 | 47 | if (command === 'voice-list') { 48 | if (argc > 1) { 49 | throw new Error("Unexpected extra arguments for voice-list"); 50 | } 51 | } else if (command === 'speech-output') { 52 | if (argc !== 8) { 53 | throw new Error("Expecting exactly 7 arguments for speech-output"); 54 | } 55 | 56 | var getWavePath = function (path) { 57 | if (path.length < 5 || !/\.wav$/i.test(path)) { 58 | throw new Error("Expecting a path ending in .wav"); 59 | } 60 | 61 | return path; 62 | }; 63 | 64 | var getInteger = function (str, lower, upper, what) { 65 | if (!/^-?\d{1,3}$/.test(str)) { 66 | throw new Error("Expected an integer for " + what); 67 | } 68 | 69 | var value = parseInt(str, 10); 70 | 71 | if (value < lower || value > upper) { 72 | throw new Error("Value for " + what + " out of range"); 73 | } 74 | 75 | return value; 76 | }; 77 | 78 | var getUnicodeFromHex = function (hex, what) { 79 | if (typeof hex !== 'string' || hex.length < 4 || hex.length % 4 !== 0) { 80 | throw new Error("Expected quad-chunked hex string for " + what); 81 | } 82 | 83 | var i; 84 | var unicode = []; 85 | 86 | for (i = 0; i < hex.length; i += 4) { 87 | unicode.push(parseInt(hex.substr(i, 4), 16)); 88 | } 89 | 90 | return String.fromCharCode.apply('', unicode); 91 | }; 92 | 93 | // See also sapi5js.py when adjusting any of these 94 | options.file = getWavePath(argv.item(1)); 95 | options.rate = getInteger(argv.item(2), -10, 10, "rate"); 96 | options.volume = getInteger(argv.item(3), 1, 100, "volume"); 97 | options.quality = getInteger(argv.item(4), 4, 39, "quality"); 98 | options.flags = getInteger(argv.item(5), 0, 16, "flags"); 99 | options.voice = getUnicodeFromHex(argv.item(6), "voice"); 100 | options.phrase = getUnicodeFromHex(argv.item(7), "phrase"); 101 | } else { 102 | throw new Error("Unrecognized command sent"); 103 | } 104 | 105 | 106 | var sapi = WScript.createObject('SAPI.SpVoice'); 107 | 108 | if (typeof sapi !== 'object') { 109 | throw new Error("SAPI does not seem to be available"); 110 | } 111 | 112 | 113 | var voices = sapi.getVoices(); 114 | 115 | if (typeof voices !== 'object') { 116 | throw new Error("Voice retrieval does not seem to be available"); 117 | } 118 | 119 | if (typeof voices.count !== 'number' || voices.count < 1) { 120 | throw new Error("There does not seem to be any voices installed"); 121 | } 122 | 123 | 124 | var i; 125 | 126 | if (command === 'voice-list') { 127 | WScript.echo('__AWESOMETTS_VOICE_LIST__'); 128 | 129 | var getHexFromUnicode = function (unicode) { 130 | if (typeof unicode !== 'string' || !unicode.length) { return ''; } 131 | 132 | var i = 0; 133 | var chunk; 134 | var hex = []; 135 | 136 | for (i = 0; i < unicode.length; ++i) { 137 | chunk = unicode.charCodeAt(i).toString(16); 138 | switch (chunk.length) { 139 | case 4: break; 140 | case 3: chunk = '0' + chunk; break; 141 | case 2: chunk = '00' + chunk; break; 142 | case 1: chunk = '000' + chunk; break; 143 | default: throw new Error("Bad chunk from toString(16) call"); 144 | } 145 | 146 | hex.push(chunk); 147 | } 148 | 149 | return hex.join(''); 150 | }; 151 | 152 | for (i = 0; i < voices.count; ++i) { 153 | var name; 154 | try { name = voices.item(i).getAttribute('name'); } 155 | catch (e) { } 156 | 157 | var language; 158 | try { language = voices.item(i).getAttribute('language'); } 159 | catch (e) { } 160 | 161 | if (typeof name == 'string' && name.length > 0) { 162 | WScript.echo( 163 | getHexFromUnicode(name) + ' ' + 164 | ( 165 | language && getHexFromUnicode(language) || 166 | getHexFromUnicode('unknown') 167 | ) 168 | ); 169 | } 170 | } 171 | } else if (command === 'speech-output') { 172 | var found = false; 173 | var voice; 174 | 175 | for (i = 0; i < voices.count; ++i) { 176 | voice = voices.item(i); 177 | 178 | if (voice.getAttribute('name') === options.voice) { 179 | found = true; 180 | sapi.voice = voice; 181 | break; 182 | } 183 | } 184 | 185 | if (!found) { 186 | throw new Error("Could not find the specified voice."); 187 | } 188 | 189 | var audioOutputStream = WScript.createObject('SAPI.SpFileStream'); 190 | 191 | if (typeof audioOutputStream !== 'object') { 192 | throw new Error("Unable to create an output file"); 193 | } 194 | 195 | audioOutputStream.format.type = options.quality; 196 | audioOutputStream.open(options.file, 3 /* SSFMCreateForWrite */); 197 | 198 | sapi.audioOutputStream = audioOutputStream; 199 | sapi.rate = options.rate; 200 | sapi.volume = options.volume; 201 | 202 | if (options.flags) { 203 | sapi.speak(options.phrase, options.flags); 204 | } else { 205 | sapi.speak(options.phrase); 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /awesometts/service/sapi5js.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for SAPI 5 on the Windows platform via JScript 21 | 22 | This module functions with the help of a JScript gateway script. See 23 | also the sapi5js.js file in this directory. 24 | """ 25 | 26 | import os 27 | import os.path 28 | 29 | from .base import Service 30 | from .common import Trait 31 | 32 | __all__ = ['SAPI5JS'] 33 | 34 | 35 | LANGUAGE_CODES = { 36 | '404': 'zh', 37 | '405': 'cs', 38 | '406': 'da', 39 | '407': 'de', 40 | '408': 'el', 41 | '409': 'en', 42 | '40a': 'es', 43 | '40c': 'fr', 44 | '410': 'it', 45 | '411': 'jp', 46 | '412': 'ko', 47 | '413': 'nl', 48 | '415': 'pl', 49 | '416': 'pt', 50 | '41d': 'sv', 51 | '41f': 'tr', 52 | '436': 'af', 53 | '439': 'hi', 54 | '804': 'zh', 55 | } 56 | 57 | 58 | class SAPI5JS(Service): 59 | """ 60 | Provides a Service-compliant implementation for SAPI 5 via JScript. 61 | """ 62 | 63 | __slots__ = [ 64 | '_binary', # path to the cscript binary 65 | '_voice_list', # list of installed voices as a list of tuples 66 | ] 67 | 68 | NAME = "Microsoft Speech API JScript" 69 | 70 | TRAITS = [Trait.TRANSCODING] 71 | 72 | _SCRIPT = os.path.join( 73 | os.path.dirname(os.path.abspath(__file__)), 74 | 'sapi5js.js', 75 | ) 76 | 77 | def __init__(self, *args, **kwargs): 78 | """ 79 | Attempts to locate the cscript binary and read the list of 80 | voices from the `cscript.exe sapi5js.js voice-list` output. 81 | 82 | However, if not running on Windows, no environment inspection is 83 | attempted and an exception is immediately raised. 84 | """ 85 | 86 | if not self.IS_WINDOWS: 87 | raise EnvironmentError("SAPI 5 is only available on Windows") 88 | 89 | super(SAPI5JS, self).__init__(*args, **kwargs) 90 | 91 | self._binary = next( 92 | fullpath 93 | for windows in [ 94 | os.environ.get('SYSTEMROOT', None), 95 | r'C:\Windows', 96 | r'C:\WinNT', 97 | ] 98 | if windows and os.path.exists(windows) 99 | for subdirectory in ['syswow64', 'system32', 'system'] 100 | for filename in ['cscript.exe'] 101 | for fullpath in [os.path.join(windows, subdirectory, filename)] 102 | if os.path.exists(fullpath) 103 | ) 104 | 105 | output = [ 106 | line.strip() 107 | for line in self.cli_output( 108 | self._binary, 109 | self._SCRIPT, 110 | 'voice-list', 111 | ) 112 | ] 113 | 114 | output = output[output.index('__AWESOMETTS_VOICE_LIST__') + 1:] 115 | 116 | def hex2uni(string): 117 | """Convert hexadecimal-escaped string back to unicode.""" 118 | return ''.join(chr(int(string[i:i + 4], 16)) 119 | for i in range(0, len(string), 4)) 120 | 121 | def convlang(string): 122 | """Get language code""" 123 | string = hex2uni(string).lower().strip() 124 | return LANGUAGE_CODES.get(string, string) 125 | 126 | self._voice_list = sorted({ 127 | (voice, voice + ' (' + language + ')') 128 | for (voice, language) in [ 129 | ( 130 | hex2uni(tokens[0]).strip(), 131 | convlang(tokens[1]).strip() 132 | ) 133 | for tokens 134 | in [line.split() for line in output] 135 | ] 136 | if voice 137 | }, key=lambda voice: voice[1].lower()) 138 | 139 | if not self._voice_list: 140 | raise EnvironmentError("No voices in `sapi5js.js voice-list`") 141 | 142 | def desc(self): 143 | """ 144 | Returns a short, static description. 145 | """ 146 | 147 | count = len(self._voice_list) 148 | return ("SAPI 5.0 via JScript (%d %s)" % 149 | (count, "voice" if count == 1 else "voices")) 150 | 151 | def options(self): 152 | """ 153 | Provides access to voice, speed, volume, and quality. 154 | """ 155 | 156 | voice_lookup = dict([ 157 | # normalized with characters w/ diacritics stripped 158 | (self.normalize(voice[0]), voice[0]) 159 | for voice in self._voice_list 160 | ] + [ 161 | # normalized with diacritics converted 162 | (self.normalize(self.util_approx(voice[0])), voice[0]) 163 | for voice in self._voice_list 164 | ]) 165 | 166 | def transform_voice(value): 167 | """Normalize and attempt to convert to official voice.""" 168 | 169 | normalized = self.normalize(value) 170 | 171 | return ( 172 | voice_lookup[normalized] if normalized in voice_lookup 173 | else value 174 | ) 175 | 176 | return [ 177 | # See also sapi5js.js when adjusting any of these 178 | 179 | dict( 180 | key='voice', 181 | label="Voice", 182 | values=self._voice_list, 183 | transform=transform_voice, 184 | ), 185 | 186 | dict( 187 | key='speed', 188 | label="Speed", 189 | values=(-10, 10), 190 | transform=int, 191 | default=0, 192 | ), 193 | 194 | dict( 195 | key='volume', 196 | label="Volume", 197 | values=(1, 100, "%"), 198 | transform=int, 199 | default=100, 200 | ), 201 | 202 | dict( 203 | key='quality', 204 | label="Quality", 205 | values=[ 206 | (4, "8 kHz, 8-bit, Mono"), 207 | (5, "8 kHz, 8-bit, Stereo"), 208 | (6, "8 kHz, 16-bit, Mono"), 209 | (7, "8 kHz, 16-bit, Stereo"), 210 | (8, "11 kHz, 8-bit, Mono"), 211 | (9, "11 kHz, 8-bit, Stereo"), 212 | (10, "11 kHz, 16-bit, Mono"), 213 | (11, "11 kHz, 16-bit, Stereo"), 214 | (12, "12 kHz, 8-bit, Mono"), 215 | (13, "12 kHz, 8-bit, Stereo"), 216 | (14, "12 kHz, 16-bit, Mono"), 217 | (15, "12 kHz, 16-bit, Stereo"), 218 | (16, "16 kHz, 8-bit, Mono"), 219 | (17, "16 kHz, 8-bit, Stereo"), 220 | (18, "16 kHz, 16-bit, Mono"), 221 | (19, "16 kHz, 16-bit, Stereo"), 222 | (20, "22 kHz, 8-bit, Mono"), 223 | (21, "22 kHz, 8-bit, Stereo"), 224 | (22, "22 kHz, 16-bit, Mono"), 225 | (23, "22 kHz, 16-bit, Stereo"), 226 | (24, "24 kHz, 8-bit, Mono"), 227 | (25, "24 kHz, 8-bit, Stereo"), 228 | (26, "24 kHz, 16-bit, Mono"), 229 | (27, "24 kHz, 16-bit, Stereo"), 230 | (28, "32 kHz, 8-bit, Mono"), 231 | (29, "32 kHz, 8-bit, Stereo"), 232 | (30, "32 kHz, 16-bit, Mono"), 233 | (31, "32 kHz, 16-bit, Stereo"), 234 | (32, "44 kHz, 8-bit, Mono"), 235 | (33, "44 kHz, 8-bit, Stereo"), 236 | (34, "44 kHz, 16-bit, Mono"), 237 | (35, "44 kHz, 16-bit, Stereo"), 238 | (36, "48 kHz, 8-bit, Mono"), 239 | (37, "48 kHz, 8-bit, Stereo"), 240 | (38, "48 kHz, 16-bit, Mono"), 241 | (39, "48 kHz, 16-bit, Stereo"), 242 | ], 243 | transform=int, 244 | default=39, 245 | ), 246 | 247 | dict( 248 | key='xml', 249 | label="XML", 250 | values=[ 251 | (0, "automatic"), 252 | (8, "always parse"), 253 | (16, "pass through"), 254 | ], 255 | transform=int, 256 | default=0, 257 | ), 258 | ] 259 | 260 | def run(self, text, options, path): 261 | """ 262 | Converts input voice and text into hex strings, writes a 263 | temporary wave file, and then transcodes to MP3. 264 | """ 265 | 266 | def hexstr(value): 267 | """Convert given unicode into hexadecimal string.""" 268 | return ''.join(['%04X' % ord(char) for char in value]) 269 | 270 | output_wav = self.path_temp('wav') 271 | 272 | try: 273 | self.cli_call( 274 | self._binary, 275 | self._SCRIPT, 276 | 'speech-output', 277 | output_wav, 278 | options['speed'], 279 | options['volume'], 280 | options['quality'], 281 | options['xml'], 282 | hexstr(options['voice']), 283 | hexstr(text), # double dash unnecessary due to hex encoding 284 | ) 285 | 286 | self.cli_transcode( 287 | output_wav, 288 | path, 289 | require=dict( 290 | size_in=4096, 291 | ), 292 | ) 293 | 294 | finally: 295 | self.path_unlink(output_wav) 296 | -------------------------------------------------------------------------------- /awesometts/service/say.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for OS X's say command 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Say'] 27 | 28 | 29 | class Say(Service): 30 | """ 31 | Provides a Service-compliant implementation for OS X's say command. 32 | """ 33 | 34 | __slots__ = [ 35 | '_binary', # path to the eSpeak binary 36 | '_voice_list', # list of installed voices as a list of tuples 37 | ] 38 | 39 | NAME = "OS X Speech Synthesis" 40 | 41 | TRAITS = [Trait.TRANSCODING] 42 | 43 | def __init__(self, *args, **kwargs): 44 | """ 45 | Attempts to read the list of voices from `say -v ?`. 46 | 47 | However, if not running on Mac OS X, no environment inspection 48 | is attempted and an exception is immediately raised. 49 | """ 50 | 51 | if not self.IS_MACOSX: 52 | raise EnvironmentError("Say is only available on Mac OS X") 53 | 54 | super(Say, self).__init__(*args, **kwargs) 55 | 56 | # n.b. voices *may* have spaces but *must* have a language code (the 57 | # language code used to be optional, but because very long voice names 58 | # will only have one space between the voice name and the language 59 | # code, it is not possible to reliably tell the difference between a 60 | # language code and a successive word in a voice name... as far as I 61 | # know, the `say -v ?` output always includes something in the 62 | # language code column, so this should be fine) 63 | import re 64 | re_voice = re.compile(r'^\s*([-\w]+( [-\w]+)*)\s+([-\w]+)') 65 | 66 | self._voice_list = [ 67 | (name, "%s (%s)" % (name, code.replace('_', '-'))) 68 | for code, name in sorted( 69 | (match.group(3), match.group(1)) 70 | for match in [re_voice.match(line) 71 | for line in self.cli_output('say', '-v', '?')] 72 | if match 73 | ) 74 | ] 75 | 76 | if not self._voice_list: 77 | raise EnvironmentError("No usable output from call to `say -v ?`") 78 | 79 | def desc(self): 80 | """ 81 | Returns a short, static description. 82 | """ 83 | 84 | return "say CLI command (%d voices)" % len(self._voice_list) 85 | 86 | def options(self): 87 | """ 88 | Provides access to voice and speed. 89 | """ 90 | 91 | voice_lookup = { 92 | self.normalize(voice[0]): voice[0] 93 | for voice in self._voice_list 94 | } 95 | 96 | def transform_voice(value): 97 | """Normalize and attempt to convert to official voice.""" 98 | 99 | normalized = self.normalize(value) 100 | 101 | return ( 102 | voice_lookup[normalized] if normalized in voice_lookup 103 | else value 104 | ) 105 | 106 | return [ 107 | dict( 108 | key='voice', 109 | label="Voice", 110 | values=self._voice_list, 111 | transform=transform_voice, 112 | ), 113 | 114 | dict( 115 | key='speed', 116 | label="Speed", 117 | values=(10, 500, "wpm"), 118 | transform=int, 119 | default=175, 120 | ), 121 | ] 122 | 123 | def run(self, text, options, path): 124 | """ 125 | Writes a temporary AIFF file and then transcodes to MP3. 126 | """ 127 | 128 | output_aiff = self.path_temp('aiff') 129 | 130 | try: 131 | self.cli_call( 132 | 'say', 133 | '-v', options['voice'], 134 | '-r', options['speed'], 135 | '-o', output_aiff, 136 | '--', text, 137 | ) 138 | 139 | self.cli_transcode( 140 | output_aiff, 141 | path, 142 | require=dict( 143 | size_in=4096, 144 | ), 145 | ) 146 | 147 | finally: 148 | self.path_unlink(output_aiff) 149 | -------------------------------------------------------------------------------- /awesometts/service/spanishdict.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for SpanishDict's text-to-speech API 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['SpanishDict'] 27 | 28 | 29 | class SpanishDict(Service): 30 | """ 31 | Provides a Service-compliant implementation for SpanishDict. 32 | """ 33 | 34 | __slots__ = [] 35 | 36 | NAME = "SpanishDict" 37 | 38 | TRAITS = [Trait.INTERNET] 39 | 40 | def desc(self): 41 | """ 42 | Returns a short, static description. 43 | """ 44 | 45 | return "SpanishDict.com (English and Spanish)" 46 | 47 | def options(self): 48 | """ 49 | Provides access to voice only. 50 | """ 51 | 52 | def transform_voice(value): 53 | """Returns normalized code if valid, otherwise value.""" 54 | 55 | normalized = self.normalize(value) 56 | if len(normalized) > 2: 57 | normalized = normalized[0:2] 58 | 59 | return normalized if normalized in ['en', 'es'] else value 60 | 61 | return [ 62 | dict( 63 | key='voice', 64 | label="Voice", 65 | values=[ 66 | ('en', "English (en)"), 67 | ('es', "Spanish (es)"), 68 | ], 69 | transform=transform_voice, 70 | default='es', 71 | ), 72 | ] 73 | 74 | def run(self, text, options, path): 75 | """ 76 | Downloads from SpanishDict directly to an MP3. 77 | """ 78 | 79 | self.net_download( 80 | path, 81 | [ 82 | ('https://audio1.spanishdict.com/audio', dict( 83 | lang=options['voice'], 84 | text=subtext, 85 | )) 86 | 87 | for subtext in self.util_split(text, 200) 88 | ], 89 | add_padding=True, 90 | require=dict(mime='audio/mpeg', size=1024), 91 | ) 92 | -------------------------------------------------------------------------------- /awesometts/service/voicetext.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """Service implementation for VoiceText's text-to-speech API""" 20 | 21 | from anki.utils import isWin as WIN32, isMac as MACOSX 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['VoiceText'] 27 | 28 | 29 | API_FORMAT = 'ogg' if WIN32 else 'aac' # very short AAC files fail on Windows 30 | 31 | API_REQUIRE = ( 32 | dict(mime='audio/wave', size=2048) if API_FORMAT == 'wav' 33 | else dict(mime='audio/' + API_FORMAT, size=256) 34 | ) 35 | 36 | VOICES = [ 37 | ('show', "Show (male)"), 38 | ('takeru', "Takeru (male)"), 39 | ('haruka', "Haruka (female)"), 40 | ('hikari', "Hikari (female)"), 41 | ('bear', "a ferocious bear"), 42 | ('santa', "Santa Claus"), 43 | ] 44 | 45 | EMOTIONAL_VOICES = ['bear', 'haruka', 'hikari', 'santa', 'takeru'] 46 | 47 | 48 | class VoiceText(Service): 49 | """Provides a Service-compliant implementation for VoiceText.""" 50 | 51 | __slots__ = [] 52 | 53 | NAME = "VoiceText" 54 | 55 | TRAITS = [Trait.INTERNET, Trait.TRANSCODING] 56 | 57 | def desc(self): 58 | """Returns a short, static description.""" 59 | 60 | return "VoiceText Web API for Japanese (%d voices)" % len(VOICES) 61 | 62 | def options(self): 63 | """ 64 | Provides access to voice, emotion, speed, pitch, and volume. 65 | 66 | Should also provide intensity, but appears to be broken on API. 67 | """ 68 | 69 | return [ 70 | dict( 71 | key='voice', 72 | label="Voice", 73 | values=VOICES, 74 | default='takeru', 75 | transform=self.normalize, 76 | ), 77 | 78 | dict( 79 | key='emotion', 80 | label="Emotion", 81 | values=[(value, value) for value in ['none', 'happiness', 82 | 'anger', 'sadness']], 83 | default='none', 84 | transform=self.normalize, 85 | ), 86 | 87 | # FIXME (and below in `parameters`): seems to trigger HTTP 400? 88 | # 89 | # If this does get added back, then the API on Google App Engine 90 | # must be updated to allow this parameter in through the sanity 91 | # checking code. 92 | # 93 | # dict( 94 | # key='intensity', 95 | # label="Intensity", 96 | # values=[(1, "weak"), (2, "normal"), 97 | # (3, "strong"), (4, "very strong")], 98 | # default=2, 99 | # transform=lambda value: min(max(int(float(value)), 1), 4), 100 | # ), 101 | 102 | dict( 103 | key='speed', 104 | label="Speed", 105 | values=(50, 400, "%"), 106 | transform=int, 107 | default=100, 108 | ), 109 | 110 | dict( 111 | key='pitch', 112 | label="Pitch", 113 | values=(50, 200, "%"), 114 | transform=int, 115 | default=100, 116 | ), 117 | 118 | dict( 119 | key='volume', 120 | label="Volume", 121 | values=(50, 200, "%"), 122 | transform=int, 123 | default=100, 124 | ), 125 | ] 126 | 127 | def run(self, text, options, path): 128 | """ 129 | Downloads from VoiceText to some initial file, then converts it 130 | WAV using mplayer if it wasn't already, and then transcodes it 131 | to MP3 using lame. 132 | 133 | If the input text is longer than 100 characters, it will be 134 | split across multiple requests, transcoded, then merged back 135 | together into a single MP3. 136 | """ 137 | 138 | if len(text) > 250: 139 | raise IOError("Input text is too long for the VoiceText service") 140 | 141 | svc_paths = [] 142 | caf_paths = [] 143 | wav_paths = [] 144 | mp3_paths = [] 145 | 146 | parameters = dict( 147 | speaker=options['voice'], 148 | format=API_FORMAT, 149 | # emotion_level=options['intensity'], 150 | speed=options['speed'], 151 | pitch=options['pitch'], 152 | volume=options['volume'], 153 | ) 154 | 155 | if options['emotion'] != 'none': 156 | if options['voice'] not in EMOTIONAL_VOICES: 157 | raise IOError( 158 | "The '%s' VoiceText voice does not allow emotion to be " 159 | "applied; choose another voice (any of %s), or set the " 160 | "emotion to 'none'." % (options['voice'], 161 | ", ".join(EMOTIONAL_VOICES)) 162 | ) 163 | parameters['emotion'] = options['emotion'] 164 | 165 | try: 166 | api_endpoint = self.ecosystem.web + '/api/voicetext' 167 | 168 | for subtext in self.util_split(text, 100): 169 | wav_path = self.path_temp('wav') 170 | wav_paths.append(wav_path) 171 | parameters['text'] = subtext 172 | 173 | if API_FORMAT == 'wav': 174 | self.net_download(wav_path, (api_endpoint, parameters), 175 | require=API_REQUIRE, awesome_ua=True) 176 | else: 177 | # n.b. We call net_dump() using a local file path after 178 | # calling net_download() instead of just calling net_dump() 179 | # directly w/ the URL so we can get direct access to HTTP 180 | # status codes from net_download() (e.g. if our VoiceText 181 | # proxy rejects the call) 182 | 183 | svc_path = self.path_temp(API_FORMAT) 184 | svc_paths.append(svc_path) 185 | self.net_download(svc_path, (api_endpoint, parameters), 186 | require=API_REQUIRE, awesome_ua=True) 187 | 188 | if MACOSX and API_FORMAT == 'aac': # avoid crashes on OS X 189 | caf_path = self.path_temp('caf') 190 | caf_paths.append(caf_path) 191 | 192 | self.cli_call('afconvert', 193 | '-d', 'aac', svc_path, 194 | '-f', 'caff', caf_path) 195 | self.cli_call('afconvert', 196 | '-d', 'I8', caf_path, 197 | '-f', 'AIFF', wav_path) 198 | 199 | else: # mplayer works just fine on Linux and Windows 200 | self.net_dump(wav_path, svc_path) 201 | 202 | if len(wav_paths) > 1: 203 | for wav_path in wav_paths: 204 | mp3_path = self.path_temp('mp3') 205 | mp3_paths.append(mp3_path) 206 | self.cli_transcode(wav_path, mp3_path) 207 | self.util_merge(mp3_paths, path) 208 | 209 | else: 210 | self.cli_transcode(wav_paths[0], path) 211 | 212 | finally: 213 | self.path_unlink(svc_paths, caf_paths, wav_paths, mp3_paths) 214 | -------------------------------------------------------------------------------- /awesometts/service/wiktionary.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Wiktionary single word pronunciations 21 | """ 22 | 23 | import os 24 | import re 25 | 26 | from .base import Service 27 | from .common import Trait 28 | 29 | __all__ = ['Wiktionary'] 30 | 31 | 32 | RE_NONWORD = re.compile(r'\W+', re.UNICODE) 33 | DEFINITE_ARTICLES = ['das', 'der', 'die', 'el', 'gli', 'i', 'il', 'l', 'la', 34 | 'las', 'le', 'les', 'lo', 'los', 'the'] 35 | 36 | TEXT_SPACE_LIMIT = 1 37 | TEXT_LENGTH_LIMIT = 75 38 | 39 | 40 | class Wiktionary(Service): 41 | """ 42 | Provides a Service-compliant implementation for Wiktionary 43 | """ 44 | 45 | __slots__ = [ 46 | ] 47 | 48 | NAME = "Wiktionary" 49 | 50 | TRAITS = [Trait.INTERNET] 51 | 52 | # In order of size as of Nov 6 2016 53 | _LANGUAGE_CODES = { 54 | 'en': 'English', 'mg': 'Malagasy', 'fr': 'French', 55 | 'sh': 'Serbo-Croatian', 'es': 'Spanish', 'zh': 'Chinese', 56 | 'ru': 'Russian', 'lt': 'Lithuanian', 'de': 'German', 57 | 'nl': 'Dutch', 'sv': 'Swedish', 'pl': 'Polish', 58 | 'ku': 'Kurdish', 'el': 'Greek', 'it': 'Italian', 59 | 'ta': 'Tamil', 'tr': 'Turkish', 'hu': 'Hungarian', 60 | 'fi': 'Finnish', 'ko': 'Korean', 'io': 'Ido', 61 | 'kn': 'Kannada', 'vi': 'Vietnamese', 'ca': 'Catalan', 62 | 'pt': 'Portuguese', 'chr': 'Cherokee', 'sr': 'Serbian', 63 | 'hi': 'Hindi', 'ja': 'Japanese', 'hy': 'Armenian', 64 | 'ro': 'Romanian', 'no': 'Norwegian', 'th': 'Thai', 65 | 'ml': 'Malayalam', 'id': 'Indonesian', 'et': 'Estonian', 66 | 'uz': 'Uzbek', 'li': 'Limburgish', 'my': 'Burmese', 67 | 'or': 'Oriya', 'te': 'Telugu', 68 | } 69 | 70 | def desc(self): 71 | """ 72 | Returns a short, static description. 73 | """ 74 | 75 | return "Wiktionary single word translations" 76 | 77 | def options(self): 78 | """ 79 | Provides access to different language versions of Wiktionary. 80 | """ 81 | 82 | return [ 83 | dict( 84 | key='voice', 85 | label="Voice", 86 | values=[(code, name) 87 | for code, name in sorted(self._LANGUAGE_CODES.items(), 88 | key=lambda x: x[1])], 89 | transform=lambda x: x, 90 | test_default='en' 91 | ), 92 | ] 93 | 94 | def modify(self, text): 95 | """ 96 | Remove punctuation but leave case as-is (sometimes it matters). 97 | 98 | If the input is multiple words and the first word is a definite 99 | article, drop it. 100 | """ 101 | 102 | text = RE_NONWORD.sub('_', text).replace('_', ' ').strip() 103 | 104 | tokenized = text.split(' ', 1) 105 | if len(tokenized) == 2: 106 | first, rest = tokenized 107 | if first in DEFINITE_ARTICLES: 108 | return rest 109 | 110 | return text 111 | 112 | def run(self, text, options, path): 113 | """ 114 | Downloads from Wiktionary directly to an OGG, and then 115 | converts to MP3. 116 | 117 | Many words (and all phrases) are not listed on Wiktionary. 118 | Thus, this will fail often. 119 | """ 120 | 121 | if text.count(' ') > TEXT_SPACE_LIMIT: 122 | raise IOError("Wiktionary does not support phrases") 123 | elif len(text) > TEXT_LENGTH_LIMIT: 124 | raise IOError("Wiktionary only supports short input") 125 | 126 | # Execute search using the text *as is* (i.e. no lowercasing) so that 127 | # Wiktionary can pick the best page (i.e. decide whether case matters) 128 | webpage = self.net_stream( 129 | ( 130 | 'https://%s.wiktionary.org/w/index.php' % options['voice'], 131 | dict( 132 | search=text, 133 | title='Special:Search', 134 | ), 135 | ), 136 | require=dict(mime='text/html'), 137 | ).decode() 138 | 139 | # Now parse the page, looking for the ogg file. This will 140 | # find at most one match, as we expect there to be no more 141 | # than one pronunciation on the wiktionary page for any 142 | # given word. This is sometimes violated if the word has 143 | # multiple pronunciations, but since there is no trivial 144 | # way to choose between them, this should be good enough 145 | # for now. 146 | matcher = re.search(r'"(//upload.wikimedia.org/[^"]+\.ogg)"', webpage) 147 | 148 | if not matcher: 149 | raise IOError("Wiktionary doesn't have any audio for this input.") 150 | 151 | ogg_url = "https:" + matcher.group(1) 152 | mp3_url = ogg_url.replace('/commons/', '/commons/transcoded/') + \ 153 | '/' + os.path.basename(ogg_url) + '.mp3' 154 | 155 | self.net_download(path, mp3_url, 156 | require=dict(mime='audio/mpeg', size=1024)) 157 | -------------------------------------------------------------------------------- /awesometts/service/yandex.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """ 20 | Service implementation for Yandex.Translate's text-to-speech API 21 | """ 22 | 23 | from .base import Service 24 | from .common import Trait 25 | 26 | __all__ = ['Yandex'] 27 | 28 | 29 | class Yandex(Service): 30 | """ 31 | Provides a Service-compliant implementation for Yandex.Translate. 32 | """ 33 | 34 | __slots__ = [ 35 | ] 36 | 37 | NAME = "Yandex.Translate" 38 | 39 | TRAITS = [Trait.INTERNET] 40 | 41 | _VOICE_CODES = { 42 | # n.b. The aliases code below assumes that no languages have any 43 | # variants and is therefore safe to always alias to the full 44 | # code from the two-character language code. If, in the future, 45 | # there are two kinds of any particular language, the alias list 46 | # logic will have to be reworked. 47 | 48 | 'ar_AE': "Arabic", 'ca_ES': "Catalan", 'cs_CZ': "Czech", 49 | 'da_DK': "Danish", 'de_DE': "German", 'el_GR': "Greek", 50 | 'en_GB': "English, British", 'es_ES': "Spanish, European", 51 | 'fi_FI': "Finnish", 'fr_FR': "French", 'it_IT': "Italian", 52 | 'nl_NL': "Dutch", 'no_NO': "Norwegian", 'pl_PL': "Polish", 53 | 'pt_PT': "Portuguese, European", 'ru_RU': "Russian", 54 | 'sv_SE': "Swedish", 'tr_TR': "Turkish", 55 | } 56 | 57 | def desc(self): 58 | """ 59 | Returns a short, static description. 60 | """ 61 | 62 | return "Yandex.Translate text-to-speech web API " \ 63 | "(%d voices)" % len(self._VOICE_CODES) 64 | 65 | def options(self): 66 | """ 67 | Provides access to voice and quality. 68 | """ 69 | 70 | voice_lookup = dict([ 71 | # two-character language codes 72 | (self.normalize(code[:2]), code) 73 | for code in self._VOICE_CODES.keys() 74 | ] + [ 75 | # aliases for Spanish, European 76 | (self.normalize(alias), 'es_ES') 77 | for alias in ['es_EU'] 78 | ] + [ 79 | # aliases for English, British 80 | (self.normalize(alias), 'en_GB') 81 | for alias in ['en_EU', 'en_UK'] 82 | ] + [ 83 | # aliases for Portuguese, European 84 | (self.normalize(alias), 'pt_PT') 85 | for alias in ['pt_EU'] 86 | ] + [ 87 | # then add/override for full names (e.g. Spanish, European) 88 | (self.normalize(name), code) 89 | for code, name in self._VOICE_CODES.items() 90 | ] + [ 91 | # then add/override for shorter names (e.g. Spanish) 92 | (self.normalize(name.split(',')[0]), code) 93 | for code, name in self._VOICE_CODES.items() 94 | ] + [ 95 | # then add/override for official voices (e.g. es_ES) 96 | (self.normalize(code), code) 97 | for code in self._VOICE_CODES.keys() 98 | ]) 99 | 100 | def transform_voice(value): 101 | """Normalize and attempt to convert to official code.""" 102 | 103 | normalized = self.normalize(value) 104 | if normalized in voice_lookup: 105 | return voice_lookup[normalized] 106 | 107 | return value 108 | 109 | return [ 110 | dict( 111 | key='voice', 112 | label="Voice", 113 | values=[(code, "%s (%s)" % (name, code.replace('_', '-'))) 114 | for code, name in sorted(self._VOICE_CODES.items())], 115 | transform=transform_voice, 116 | ), 117 | dict( 118 | key='quality', 119 | label="Quality", 120 | values=[ 121 | ('lo', 'low'), 122 | ('hi', 'high'), 123 | ], 124 | default='hi', 125 | transform=lambda value: value.lower().strip()[:2], 126 | ), 127 | ] 128 | 129 | def run(self, text, options, path): 130 | """ 131 | Downloads from Yandex directly to an MP3. 132 | """ 133 | 134 | self.net_download( 135 | path, 136 | [ 137 | ('http://tts.voicetech.yandex.net/tts', dict( 138 | format='mp3', 139 | quality=options['quality'], 140 | lang=options['voice'], 141 | text=subtext, 142 | )) 143 | 144 | # n.b. limit seems to be much higher than 750, but this is 145 | # a safe place to start (the web UI limits the user to 100) 146 | for subtext in self.util_split(text, 750) 147 | ], 148 | require=dict(mime='audio/mpeg', size=1024), 149 | add_padding=True, 150 | ) 151 | -------------------------------------------------------------------------------- /awesometts/service/youdao.py: -------------------------------------------------------------------------------- 1 | # -*- coding: utf-8 -*- 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | """Youdao Dictionary""" 20 | 21 | from .base import Service 22 | from .common import Trait 23 | 24 | __all__ = ['Youdao'] 25 | 26 | 27 | VOICE_CODES = [ 28 | ('en-GB', ("English, British", 1)), 29 | ('en-US', ("English, American", 2)), 30 | ('en', ("English, alternative", 3)), 31 | ] 32 | 33 | VOICE_LOOKUP = dict(VOICE_CODES) 34 | 35 | 36 | class Youdao(Service): 37 | """Provides a Service implementation for Youdao Dictionary.""" 38 | 39 | __slots__ = [] 40 | 41 | NAME = "Youdao Dictionary" 42 | 43 | TRAITS = [Trait.INTERNET] 44 | 45 | def desc(self): 46 | """Returns a static description.""" 47 | 48 | return "Youdao (American and British English)" 49 | 50 | def options(self): 51 | """Returns an option to select the voice.""" 52 | 53 | voice_lookup = dict([ 54 | (self.normalize(alias), 'en-GB') 55 | for alias in ['en-EU', 'en-UK'] 56 | ] + [ 57 | (self.normalize(alias), 'en') 58 | for alias in ['English', 'en', 'eng'] 59 | ] + [ 60 | (self.normalize(code), code) 61 | for code in VOICE_LOOKUP.keys() 62 | ]) 63 | 64 | def transform_voice(value): 65 | """Normalize and attempt to convert to official code.""" 66 | 67 | normalized = self.normalize(value) 68 | return (voice_lookup[normalized] 69 | if normalized in voice_lookup else value) 70 | 71 | return [ 72 | dict( 73 | key='voice', 74 | label="Voice", 75 | values=[(key, description) 76 | for key, (description, _) in VOICE_CODES], 77 | transform=transform_voice, 78 | default='en-US', 79 | ), 80 | ] 81 | 82 | def run(self, text, options, path): 83 | """Downloads from dict.youdao.com directly to an MP3.""" 84 | 85 | self.net_download( 86 | path, 87 | [ 88 | ('http://dict.youdao.com/dictvoice', dict( 89 | audio=subtext, 90 | type=VOICE_LOOKUP[options['voice']][1], 91 | )) 92 | for subtext in self.util_split(text, 1000) 93 | ], 94 | require=dict(mime='audio/mpeg', size=256), 95 | add_padding=True, 96 | ) 97 | -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kelciour/awesometts-anki-addon/6045011130d59afb8bd3d745c0e8e44e704dc94e/tests/__init__.py -------------------------------------------------------------------------------- /tests/test_run.py: -------------------------------------------------------------------------------- 1 | from urllib.error import HTTPError 2 | from warnings import warn 3 | 4 | from anki_testing import anki_running 5 | from pytest import raises 6 | 7 | 8 | def test_addon_initialization(): 9 | with anki_running() as anki_app: 10 | import awesometts 11 | awesometts.browser_menus() # mass generator and MP3 stripper 12 | awesometts.cache_control() # automatically clear the media cache regularly 13 | awesometts.cards_button() # on-the-fly templater helper in card view 14 | awesometts.config_menu() # provides access to configuration dialog 15 | awesometts.editor_button() # single audio clip generator button 16 | awesometts.reviewer_hooks() # on-the-fly playback/shortcuts, context menus 17 | awesometts.sound_tag_delays() # delayed playing of stored [sound]s in review 18 | awesometts.temp_files() # remove temporary files upon session exit 19 | awesometts.update_checker() # if enabled, runs the add-on update checker 20 | awesometts.window_shortcuts() # enable/update shortcuts for add-on windows 21 | 22 | 23 | def test_gui(): 24 | pass 25 | 26 | 27 | class Success(Exception): 28 | pass 29 | 30 | 31 | def re_raise(exception, text="Not available re_raise"): 32 | if isinstance(exception, HTTPError): 33 | print('Unable to test (HTTP Error)') 34 | raise Success() 35 | raise exception 36 | 37 | 38 | def get_default_options(addon, svc_id): 39 | available_options = addon.router.get_options(svc_id) 40 | 41 | options = {} 42 | 43 | for option in available_options: 44 | key = option['key'] 45 | if 'test_default' in option: 46 | value = option['test_default'] 47 | elif 'default' in option: 48 | value = option['default'] 49 | else: 50 | value = option['values'][0] 51 | if isinstance(value, tuple): 52 | value = value[0] 53 | options[key] = value 54 | 55 | return options 56 | 57 | 58 | def test_services(): 59 | """Tests all services (except iSpeech) using a single word. 60 | 61 | Retrieving, processing, and playing of word "test" will be tested, 62 | using default (or first available) options. To expose a specific 63 | value of an option for testing purposes only, use test_default. 64 | """ 65 | require_key = ['iSpeech'] 66 | it_fails = ['Baidu Translate', 'Duden', 'NAVER Translate'] 67 | 68 | with anki_running() as anki_app: 69 | 70 | from awesometts import addon 71 | 72 | def success_if_path_exists_and_plays(path): 73 | import os 74 | 75 | # play (and hope that we have no errors) 76 | addon.player.preview(path) 77 | 78 | # and after making sure that the path exists 79 | if os.path.exists(path): 80 | # claim success 81 | raise Success() 82 | 83 | callbacks = { 84 | 'okay': success_if_path_exists_and_plays, 85 | 'fail': re_raise 86 | } 87 | 88 | for svc_id, name, in addon.router.get_services(): 89 | 90 | if name in require_key: 91 | warn(f'Skipping {name} (no API key)') 92 | continue 93 | 94 | if name in it_fails: 95 | warn(f'Skipping {name} - known to fail; if you can fix it, please open a PR') 96 | continue 97 | 98 | print(f'Testing {name}') 99 | 100 | options = get_default_options(addon, svc_id) 101 | 102 | with raises(Success): 103 | addon.router( 104 | svc_id=svc_id, 105 | text='test', 106 | options=options, 107 | callbacks=callbacks, 108 | async_variable=False 109 | ) 110 | -------------------------------------------------------------------------------- /tools/install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | addon_id="301952613" 20 | 21 | set -e 22 | 23 | if [ -z "$1" ] 24 | then 25 | echo 'Please specify your Anki addons directory.' 1>&2 26 | echo 1>&2 27 | echo " Usage: $0 " 1>&2 28 | echo " e.g.: $0 ~/.local/share/Anki2/addons21" 1>&2 29 | exit 1 30 | fi 31 | 32 | target=${1%/} 33 | 34 | case $target in 35 | */addons21) 36 | ;; 37 | 38 | *) 39 | echo 'Expected target path to end in "/addons21".' 1>&2 40 | exit 1 41 | esac 42 | 43 | case $target in 44 | /*) 45 | ;; 46 | 47 | *) 48 | target=$PWD/$target 49 | esac 50 | 51 | if [ ! -d "$target" ] 52 | then 53 | echo "$target is not a directory." 1>&2 54 | exit 1 55 | fi 56 | 57 | mkdir -p "$target/$addon_id" 58 | 59 | if [ -f "$target/$addon_id/awesometts/config.db" ] 60 | then 61 | echo 'Saving configuration...' 62 | saveConf=$(mktemp /tmp/config.XXXXXXXXXX.db) 63 | cp -v "$target/$addon_id/awesometts/config.db" "$saveConf" 64 | fi 65 | 66 | echo 'Cleaning up...' 67 | rm -fv "$target/$addon_id/__init__.py"* 68 | rm -rfv "$target/$addon_id/awesometts" 69 | 70 | oldPwd=$PWD 71 | cd "$(dirname "$0")/.." || exit 1 72 | 73 | packageZip=$(mktemp /tmp/package.XXXXXXXXXX.zip) 74 | ./tools/package.sh "$packageZip" 75 | unzip "$packageZip" -d "$target/$addon_id" 76 | rm -fv "$packageZip" 77 | 78 | cd "$oldPwd" || exit 1 79 | 80 | if [ -n "$saveConf" ] 81 | then 82 | echo 'Restoring configuration...' 83 | mv -v "$saveConf" "$target/$addon_id/awesometts/config.db" 84 | fi 85 | -------------------------------------------------------------------------------- /tools/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | set -e 20 | 21 | if [ -z "$1" ] 22 | then 23 | echo 'Please specify where you want to save the package.' 1>&2 24 | echo 1>&2 25 | echo " Usage: $0 " 1>&2 26 | echo " e.g.: $0 ~/AwesomeTTS.zip" 1>&2 27 | exit 1 28 | fi 29 | 30 | target=$1 31 | 32 | case $target in 33 | *.zip) 34 | ;; 35 | 36 | *) 37 | echo 'Expected target path to end in a ".zip" extension.' 1>&2 38 | exit 1 39 | esac 40 | 41 | case $target in 42 | /*) 43 | ;; 44 | 45 | *) 46 | target=$PWD/$target 47 | esac 48 | 49 | if [ -e "$target" ] 50 | then 51 | echo 'Removing old package...' 52 | rm -fv "$target" 53 | fi 54 | 55 | oldPwd=$PWD 56 | cd "$(dirname "$0")/.." || exit 1 57 | 58 | echo 'Packing zip file...' 59 | zip -9 "$target" \ 60 | awesometts/blank.mp3 \ 61 | awesometts/LICENSE.txt \ 62 | awesometts/*.py \ 63 | awesometts/gui/*.py \ 64 | awesometts/gui/icons/*.png \ 65 | awesometts/service/*.py \ 66 | awesometts/service/*.js \ 67 | __init__.py 68 | 69 | cd "$oldPwd" || exit 1 70 | -------------------------------------------------------------------------------- /tools/symlink.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | # AwesomeTTS text-to-speech add-on for Anki 4 | # Copyright (C) 2010-Present Anki AwesomeTTS Development Team 5 | # 6 | # This program is free software: you can redistribute it and/or modify 7 | # it under the terms of the GNU General Public License as published by 8 | # the Free Software Foundation, either version 3 of the License, or 9 | # (at your option) any later version. 10 | # 11 | # This program is distributed in the hope that it will be useful, 12 | # but WITHOUT ANY WARRANTY; without even the implied warranty of 13 | # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 14 | # GNU General Public License for more details. 15 | # 16 | # You should have received a copy of the GNU General Public License 17 | # along with this program. If not, see . 18 | 19 | addon_id="301952613" 20 | 21 | set -e 22 | 23 | if [ -z "$1" ] 24 | then 25 | echo 'Please specify your Anki addons directory.' 1>&2 26 | echo 1>&2 27 | echo " Usage: $0 " 1>&2 28 | echo " e.g.: $0 ~/.local/share/Anki2/addons21" 1>&2 29 | exit 1 30 | fi 31 | 32 | target=${1%/} 33 | 34 | case $target in 35 | */addons21) 36 | ;; 37 | 38 | *) 39 | echo 'Expected target path to end in "/addons21".' 1>&2 40 | exit 1 41 | esac 42 | 43 | case $target in 44 | /*) 45 | ;; 46 | 47 | *) 48 | target=$PWD/$target 49 | esac 50 | 51 | if [ ! -d "$target" ] 52 | then 53 | echo "$target is not a directory." 1>&2 54 | exit 1 55 | fi 56 | 57 | mkdir -p "$target/$addon_id" 58 | 59 | if [ -f "$target/$addon_id/awesometts/config.db" ] 60 | then 61 | echo 'Saving configuration...' 62 | saveConf=$(mktemp /tmp/config.XXXXXXXXXX.db) 63 | cp -v "$target/$addon_id/awesometts/config.db" "$saveConf" 64 | fi 65 | 66 | if [ -d "$target/$addon_id/awesometts/.cache" ] 67 | then 68 | echo 'Saving cache...' 69 | saveCache=$(mktemp -d /tmp/anki_cacheXXXXXXXXXX) 70 | cp -rv "$target/$addon_id/awesometts/.cache/*" "$saveCache/" 71 | fi 72 | 73 | echo 'Cleaning up...' 74 | rm -fv "$target/$addon_id/__init__.py"* 75 | rm -rfv "$target/$addon_id/awesometts" 76 | 77 | oldPwd=$PWD 78 | cd "$(dirname "$0")/.." || exit 1 79 | 80 | echo 'Linking...' 81 | ln -sv "$PWD/$addon_id/__init__.py" "$target" 82 | ln -sv "$PWD/$addon_id/awesometts" "$target" 83 | 84 | cd "$oldPwd" || exit 1 85 | 86 | if [ -n "$saveConf" ] 87 | then 88 | echo 'Restoring configuration...' 89 | mv -v "$saveConf" "$target/$addon_id/awesometts/config.db" 90 | fi 91 | 92 | if [ -n "$saveCache" ] 93 | then 94 | echo 'Restoring cache...' 95 | mv -rv "$saveConf/*" "$target/$addon_id/awesometts/.cache/" 96 | fi 97 | -------------------------------------------------------------------------------- /tools/test.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | sudo apt-get install mplayer lame 4 | 5 | for required_program in 'git' 'mplayer' 'python3' 'lame' 'pip3' 6 | do 7 | hash ${required_program} 2>/dev/null || { 8 | echo >&2 "$required_program is required but it is not installed. Please install $required_program first." 9 | exit 1 10 | } 11 | done 12 | 13 | bash anki_testing/install_anki.sh 14 | 15 | # never versions attempt to read __init__.py on the root level which leads to multiple error 16 | python3 -m pip install pytest==3.7.1 17 | 18 | python3 -m pytest tests 19 | -------------------------------------------------------------------------------- /user_files/README.txt: -------------------------------------------------------------------------------- 1 | Any files placed in this folder will be preserved when the add-on is upgraded. --------------------------------------------------------------------------------