├── .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 | [](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.
--------------------------------------------------------------------------------