mirror of
https://github.com/xiaoyifang/goldendict-ng.git
synced 2024-11-23 20:14:05 +00:00
0
This commit is contained in:
parent
2b98b506fe
commit
ce5b9c4fb3
6
.gitignore
vendored
6
.gitignore
vendored
|
@ -33,7 +33,7 @@ GoldenDict.xcodeproj/
|
|||
*.suo
|
||||
*.vcxproj.user
|
||||
/.idea
|
||||
/.vs
|
||||
.vs
|
||||
/.vscode
|
||||
/.qtc_clangd
|
||||
|
||||
|
@ -56,3 +56,7 @@ GoldenDict_resource.rc
|
|||
*.orig
|
||||
|
||||
node_modules
|
||||
|
||||
# tts testing files
|
||||
*.ogg
|
||||
*.mp3
|
4
src/global_network_access_manager.hh
Normal file
4
src/global_network_access_manager.hh
Normal file
|
@ -0,0 +1,4 @@
|
|||
#pragma once
|
||||
|
||||
#include <QNetworkAccessManager>
|
||||
Q_APPLICATION_STATIC( QNetworkAccessManager, globalNetworkAccessManager )
|
54
src/tts/README.md
Normal file
54
src/tts/README.md
Normal file
|
@ -0,0 +1,54 @@
|
|||
# Cloud TTS
|
||||
|
||||
## Add a new service checklist
|
||||
|
||||
* Read `service.h`.
|
||||
* Implement `Service::speak`.
|
||||
* Implement `Service::stop`.
|
||||
* Implement `ServiceConfigWidget`, which will be embedded in `ConfigWindow`.
|
||||
* Add the `Service` to `ServiceController`.
|
||||
* Add the `ServiceConfigWidget` to `ConfigWindow`.
|
||||
* DONE.
|
||||
|
||||
## Design Goals
|
||||
|
||||
Allow modifying / evolving any one of the services arbitrarily without incurring the need to touch another.
|
||||
|
||||
Avoid almost all temptation to do 💩 abstraction 💩 unless absolutely necessary.
|
||||
|
||||
## Code
|
||||
|
||||
### Config
|
||||
|
||||
```
|
||||
(1) Service ConfigWidet --write--> (2) Service's config file --create--> (3) Live Service Object
|
||||
```
|
||||
|
||||
* Config Serialization+Saving and Service state mutating will not happen in parallel or successively.
|
||||
* (1) will neither mutate nor access (3).
|
||||
* construct (3) only according to (2).
|
||||
|
||||
### Object management
|
||||
|
||||
* Service construction will be done on the service consumer side
|
||||
* Service can be cast to `Service`, which only has `speak/stop` and destructor.
|
||||
* The service consumer should not care
|
||||
anything else after construction.
|
||||
|
||||
### Config Window
|
||||
|
||||
Similar to KDE's Settings module (KCM).
|
||||
Every service simply provides a config widget on its own, and the config window simply loads the Widget.
|
||||
|
||||
### No exception
|
||||
|
||||
* Handle errors religiously and immediately, and report to users if user attention/action is required.
|
||||
|
||||
## Rational
|
||||
|
||||
* Services are different and testing them is hard (cloud tts usually needs an account).
|
||||
* Do not assume services have any similarity other than the fact they may `speak`.
|
||||
* Services on earth are limited, thus the boilerplate caused by fewer useless abstractions is also limited.
|
||||
* The service consumer will use services incredibly and insanely creative in the future.
|
||||
* Maintaining two code paths of object creation & mutating is a waste of time.
|
||||
* Just save config to disk, and construct objects according to what's in the disk.
|
35
src/tts/config_file_main.cc
Normal file
35
src/tts/config_file_main.cc
Normal file
|
@ -0,0 +1,35 @@
|
|||
#include "config_file_main.hh"
|
||||
|
||||
#include <QFileInfo>
|
||||
#include <QSaveFile>
|
||||
|
||||
|
||||
namespace TTS {
|
||||
|
||||
auto current_service_txt = "current_service.txt";
|
||||
|
||||
QString get_service_name_from_path( const QDir & configPath )
|
||||
{
|
||||
qDebug() << configPath;
|
||||
if ( !QFileInfo::exists( configPath.absoluteFilePath( current_service_txt ) ) ) {
|
||||
save_service_name_to_path( configPath, "azure" );
|
||||
}
|
||||
QFile f( configPath.filePath( current_service_txt ) );
|
||||
if ( !f.open( QFile::ReadOnly ) ) {
|
||||
throw std::runtime_error( "cannot open service name" ); // TODO
|
||||
}
|
||||
QString ret = f.readAll();
|
||||
f.close();
|
||||
return ret;
|
||||
}
|
||||
|
||||
void save_service_name_to_path( const QDir & configPath, QUtf8StringView serviceName )
|
||||
{
|
||||
QSaveFile f( configPath.absoluteFilePath( current_service_txt ) );
|
||||
if ( !f.open( QFile::WriteOnly ) ) {
|
||||
throw std::runtime_error( "Cannot write service name" );
|
||||
}
|
||||
f.write( serviceName.data(), serviceName.length() );
|
||||
f.commit();
|
||||
};
|
||||
} // namespace TTS
|
7
src/tts/config_file_main.hh
Normal file
7
src/tts/config_file_main.hh
Normal file
|
@ -0,0 +1,7 @@
|
|||
#pragma once
|
||||
#include <QDir>
|
||||
|
||||
namespace TTS {
|
||||
QString get_service_name_from_path( const QDir & configRootPath );
|
||||
void save_service_name_to_path( const QDir & configPath, QUtf8StringView serviceName );
|
||||
} // namespace TTS
|
153
src/tts/config_window.cc
Normal file
153
src/tts/config_window.cc
Normal file
|
@ -0,0 +1,153 @@
|
|||
#include "tts/config_window.hh"
|
||||
#include "tts/services/azure.hh"
|
||||
#include "tts/services/dummy.hh"
|
||||
#include "tts/services/local_command.hh"
|
||||
#include "tts/config_file_main.hh"
|
||||
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QLabel>
|
||||
#include <QPushButton>
|
||||
#include <QLineEdit>
|
||||
|
||||
#include <QStringLiteral>
|
||||
|
||||
namespace TTS {
|
||||
|
||||
//TODO: split preview pane to a seprate file.
|
||||
void ConfigWindow::setupUi()
|
||||
{
|
||||
setWindowTitle( "Service Config" );
|
||||
this->setAttribute( Qt::WA_DeleteOnClose );
|
||||
this->setWindowModality( Qt::WindowModal );
|
||||
this->setWindowFlag( Qt::Dialog );
|
||||
|
||||
MainLayout = new QGridLayout( this );
|
||||
|
||||
configPane = new QGroupBox( "Service Config", this );
|
||||
auto * previewPane = new QGroupBox( "Audio Preview", this );
|
||||
|
||||
configPane->setSizePolicy( QSizePolicy::MinimumExpanding, QSizePolicy::MinimumExpanding );
|
||||
previewPane->setSizePolicy( QSizePolicy::Fixed, QSizePolicy::MinimumExpanding );
|
||||
|
||||
configPane->setLayout( new QVBoxLayout() );
|
||||
previewPane->setLayout( new QVBoxLayout() );
|
||||
|
||||
auto * serviceSelectLayout = new QHBoxLayout( nullptr );
|
||||
auto * serviceLabel = new QLabel( "Select service", this );
|
||||
serviceSelector = new QComboBox();
|
||||
serviceSelector->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Maximum );
|
||||
|
||||
serviceSelectLayout->addWidget( serviceLabel );
|
||||
serviceSelectLayout->addWidget( serviceSelector );
|
||||
|
||||
previewLineEdit = new QLineEdit( this );
|
||||
previewButton = new QPushButton( "Preview", this );
|
||||
|
||||
previewPane->layout()->addWidget( previewLineEdit );
|
||||
previewPane->layout()->addWidget( previewButton );
|
||||
qobject_cast< QVBoxLayout * >( previewPane->layout() )->addStretch();
|
||||
|
||||
buttonBox = new QDialogButtonBox( QDialogButtonBox::Ok | QDialogButtonBox::Cancel | QDialogButtonBox::Help, this );
|
||||
MainLayout->addLayout( serviceSelectLayout, 0, 0, 1, 2 );
|
||||
MainLayout->addWidget( configPane, 1, 0, 1, 1 );
|
||||
MainLayout->addWidget( previewPane, 1, 1, 1, 1 );
|
||||
MainLayout->addWidget( buttonBox, 2, 0, 1, 2 );
|
||||
MainLayout->addWidget(
|
||||
new QLabel(
|
||||
R"(<font color="red">Experimental feature. The default API key may stop working at anytime. Feedback & Coding help are welcomed. </font>)",
|
||||
this ),
|
||||
3,
|
||||
0,
|
||||
1,
|
||||
2 );
|
||||
}
|
||||
|
||||
ConfigWindow::ConfigWindow( QWidget * parent, const QString & configRootPath ):
|
||||
QWidget( parent, Qt::Window ),
|
||||
configRootDir( configRootPath )
|
||||
{
|
||||
configRootDir.mkpath( QStringLiteral( "ctts" ) );
|
||||
configRootDir.cd( QStringLiteral( "ctts" ) );
|
||||
|
||||
|
||||
this->setupUi();
|
||||
|
||||
serviceSelector->addItem( "Azure Text to Speech", QStringLiteral( "azure" ) );
|
||||
serviceSelector->addItem( "Local Command Line", QStringLiteral( "local_command" ) );
|
||||
serviceSelector->addItem( "Dummy", QStringLiteral( "dummy" ) );
|
||||
|
||||
|
||||
this->currentService = get_service_name_from_path( configRootDir );
|
||||
|
||||
if ( auto i = serviceSelector->findData( this->currentService ); i != -1 ) {
|
||||
serviceSelector->setCurrentIndex( i );
|
||||
}
|
||||
|
||||
|
||||
connect( previewButton, &QPushButton::clicked, this, [ this ] {
|
||||
this->serviceConfigUI->save();
|
||||
|
||||
|
||||
if ( currentService == "azure" ) {
|
||||
previewService.reset( TTS::AzureService::Construct( this->configRootDir ) );
|
||||
}
|
||||
else if ( currentService == "local_command" ) {
|
||||
auto * s = new TTS::LocalCommandService( this->configRootDir );
|
||||
s->loadCommandFromConfigFile(); // TODO:: error unhandled.
|
||||
previewService.reset( s );
|
||||
}
|
||||
else {
|
||||
previewService.reset( new TTS::DummyService() );
|
||||
}
|
||||
|
||||
if ( previewService != nullptr ) {
|
||||
previewService->speak( previewLineEdit->text().toUtf8() );
|
||||
}
|
||||
else {
|
||||
exit( 1 ); // TODO
|
||||
}
|
||||
} );
|
||||
|
||||
|
||||
updateConfigPaneBasedOnCurrentService();
|
||||
|
||||
connect( serviceSelector, &QComboBox::currentIndexChanged, this, [ this ] {
|
||||
updateConfigPaneBasedOnCurrentService();
|
||||
} );
|
||||
|
||||
connect( buttonBox, &QDialogButtonBox::accepted, this, [ this ]() {
|
||||
qDebug() << "accept";
|
||||
this->serviceConfigUI->save();
|
||||
save_service_name_to_path( configRootDir, this->serviceSelector->currentData().toByteArray() );
|
||||
|
||||
emit this->service_changed();
|
||||
this->close();
|
||||
} );
|
||||
|
||||
connect( buttonBox, &QDialogButtonBox::rejected, this, [ this ]() {
|
||||
qDebug() << "rejected";
|
||||
this->close();
|
||||
} );
|
||||
|
||||
connect( buttonBox->button( QDialogButtonBox::Help ), &QPushButton::clicked, this, [ this ]() {
|
||||
qDebug() << "help";
|
||||
} );
|
||||
}
|
||||
|
||||
|
||||
void ConfigWindow::updateConfigPaneBasedOnCurrentService()
|
||||
{
|
||||
if ( serviceSelector->currentData() == "azure" ) {
|
||||
serviceConfigUI.reset( new TTS::AzureConfigWidget( this, this->configRootDir ) );
|
||||
}
|
||||
else if ( serviceSelector->currentData() == "local_command" ) {
|
||||
serviceConfigUI.reset( new TTS::LocalCommandConfigWidget( this, this->configRootDir ) );
|
||||
}
|
||||
else {
|
||||
serviceConfigUI.reset( new TTS::DummyConfigWidget( this ) );
|
||||
}
|
||||
configPane->layout()->addWidget( serviceConfigUI.get() );
|
||||
}
|
||||
} // namespace TTS
|
42
src/tts/config_window.hh
Normal file
42
src/tts/config_window.hh
Normal file
|
@ -0,0 +1,42 @@
|
|||
#pragma once
|
||||
|
||||
#include "tts/services/azure.hh"
|
||||
#include <QDialogButtonBox>
|
||||
#include <QGridLayout>
|
||||
#include <QGroupBox>
|
||||
#include <QWidget>
|
||||
|
||||
namespace TTS {
|
||||
class ConfigWindow: public QWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit ConfigWindow( QWidget * parent, const QString & configRootPath );
|
||||
|
||||
signals:
|
||||
void service_changed();
|
||||
|
||||
private:
|
||||
QGridLayout * MainLayout;
|
||||
QGroupBox * configPane;
|
||||
|
||||
QDialogButtonBox * buttonBox;
|
||||
QLineEdit * previewLineEdit;
|
||||
QPushButton * previewButton;
|
||||
|
||||
QString currentService;
|
||||
QDir configRootDir;
|
||||
|
||||
QComboBox * serviceSelector;
|
||||
|
||||
std::unique_ptr< TTS::Service > previewService;
|
||||
std::unique_ptr< TTS::ServiceConfigWidget > serviceConfigUI;
|
||||
|
||||
void setupUi();
|
||||
|
||||
private slots:
|
||||
void updateConfigPaneBasedOnCurrentService();
|
||||
};
|
||||
|
||||
} // namespace TTS
|
1
src/tts/dev_helpers/README.md
Normal file
1
src/tts/dev_helpers/README.md
Normal file
|
@ -0,0 +1 @@
|
|||
Files to test various services.
|
11
src/tts/dev_helpers/voice.hurl
Normal file
11
src/tts/dev_helpers/voice.hurl
Normal file
|
@ -0,0 +1,11 @@
|
|||
POST https://eastus.tts.speech.microsoft.com/cognitiveservices/v1
|
||||
|
||||
Ocp-Apim-Subscription-Key: b9885138792d4403a8ccf1a34553351d
|
||||
X-Microsoft-OutputFormat: audio-16khz-64kbitrate-mono-mp3
|
||||
Content-Type: application/ssml+xml
|
||||
User-Agent: WhatEver
|
||||
<speak version='1.0' xml:lang='en-US'>
|
||||
<voice name='en-US-LunaNeural'>
|
||||
hello world
|
||||
</voice>
|
||||
</speak>
|
3
src/tts/dev_helpers/voicelist.hurl
Normal file
3
src/tts/dev_helpers/voicelist.hurl
Normal file
|
@ -0,0 +1,3 @@
|
|||
GET https://eastus.tts.speech.microsoft.com/cognitiveservices/voices/list
|
||||
|
||||
Ocp-Apim-Subscription-Key: b9885138792d4403a8ccf1a34553351d
|
13
src/tts/error_dialog.hh
Normal file
13
src/tts/error_dialog.hh
Normal file
|
@ -0,0 +1,13 @@
|
|||
#pragma once
|
||||
|
||||
#include <QMessageBox>
|
||||
|
||||
namespace TTS {
|
||||
void reportError( const QString & errorString )
|
||||
{
|
||||
QMessageBox msgBox{};
|
||||
msgBox.setText( "Text to speech failed: " % errorString );
|
||||
msgBox.setIcon( QMessageBox::Warning );
|
||||
msgBox.exec();
|
||||
}
|
||||
} // namespace TTS
|
44
src/tts/service.hh
Normal file
44
src/tts/service.hh
Normal file
|
@ -0,0 +1,44 @@
|
|||
#pragma once
|
||||
|
||||
#include <QWidget>
|
||||
#include <optional>
|
||||
|
||||
/*
|
||||
*
|
||||
* We want maximum decoupling between different services.
|
||||
*
|
||||
* Things needed by Services should be added to specific services.
|
||||
*
|
||||
* Consider other options before modifying this file.
|
||||
*
|
||||
*/
|
||||
|
||||
namespace TTS {
|
||||
class Service: public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public slots:
|
||||
virtual void speak( QUtf8StringView s ) noexcept {};
|
||||
virtual void stop() noexcept {}
|
||||
signals:
|
||||
/// @brief User facing error reporting.
|
||||
/// Service::speak is likely async, error cannot be reported at return position of speak().
|
||||
void errorOccured( const QString & errorString );
|
||||
};
|
||||
|
||||
class ServiceConfigWidget: public QWidget
|
||||
{
|
||||
public:
|
||||
explicit ServiceConfigWidget( QWidget * parent ):
|
||||
QWidget( parent )
|
||||
{
|
||||
}
|
||||
|
||||
/// Ask the service to save it's config.
|
||||
/// @return if failed, return a string that contains Error message.
|
||||
virtual std::optional< std::string > save() noexcept
|
||||
{
|
||||
return {};
|
||||
}
|
||||
};
|
||||
} // namespace TTS
|
276
src/tts/services/azure.cc
Normal file
276
src/tts/services/azure.cc
Normal file
|
@ -0,0 +1,276 @@
|
|||
#include "tts/services/azure.hh"
|
||||
#include "global_network_access_manager.hh"
|
||||
|
||||
#include <QAudioOutput>
|
||||
#include <QLineEdit>
|
||||
#include <QFormLayout>
|
||||
#include <QLabel>
|
||||
#include <fmt/format.h>
|
||||
|
||||
namespace TTS {
|
||||
|
||||
|
||||
static const char * AzureSaveFileName = "azure.json";
|
||||
|
||||
static const char * hostUrlBody = "tts.speech.microsoft.com/cognitiveservices";
|
||||
|
||||
|
||||
/// @brief this is not visible to service consumers
|
||||
struct AzureConfig
|
||||
{
|
||||
QString apiKey;
|
||||
QString region;
|
||||
QString voiceShortName;
|
||||
|
||||
/// @brief Load file. Create a default one on failure.
|
||||
/// @param configFilePath
|
||||
/// @return Return null if the file absolutely cannot be accessed.
|
||||
[[nodiscard]] static std::optional< AzureConfig > loadFromFile( const QString & configFilePath );
|
||||
[[nodiscard]] static bool saveToFile( const QString & configFilePath, const AzureConfig & );
|
||||
};
|
||||
|
||||
bool AzureService::private_initialize()
|
||||
{
|
||||
auto ret_config = AzureConfig::loadFromFile( azureConfigFile );
|
||||
if ( !ret_config.has_value() ) {
|
||||
throw std::runtime_error( "TODO" );
|
||||
}
|
||||
|
||||
voiceShortName = ret_config->voiceShortName.toStdString();
|
||||
|
||||
request = new QNetworkRequest();
|
||||
request->setUrl( QUrl( QString( QStringLiteral( "https://%1.tts.speech.microsoft.com/cognitiveservices/v1" ) )
|
||||
.arg( ret_config->region ) ) );
|
||||
request->setRawHeader( "User-Agent", "WhatEver" );
|
||||
request->setRawHeader( "Ocp-Apim-Subscription-Key", ret_config->apiKey.toLatin1() );
|
||||
request->setRawHeader( "Content-Type", "application/ssml+xml" );
|
||||
request->setRawHeader( "X-Microsoft-OutputFormat", "ogg-48khz-16bit-mono-opus" );
|
||||
|
||||
player = new QMediaPlayer();
|
||||
|
||||
auto * audioOutput = new QAudioOutput;
|
||||
audioOutput->setVolume( 50 );
|
||||
player->setAudioOutput( audioOutput );
|
||||
|
||||
connect( player, &QMediaPlayer::errorOccurred, this, &AzureService::mediaErrorOccur );
|
||||
connect( player, &QMediaPlayer::mediaStatusChanged, this, &AzureService::mediaStatus );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
AzureService * AzureService::Construct( const QDir & configRootPath )
|
||||
{
|
||||
auto azure = new AzureService();
|
||||
|
||||
azure->azureConfigFile = configRootPath.filePath( AzureSaveFileName );
|
||||
|
||||
if ( azure->private_initialize() ) {
|
||||
return azure;
|
||||
}
|
||||
return nullptr;
|
||||
};
|
||||
|
||||
void AzureService::speak( QUtf8StringView s ) noexcept
|
||||
{
|
||||
std::string y = fmt::format( R"(<speak version='1.0' xml:lang='en-US'>
|
||||
<voice name='{}'>
|
||||
{}
|
||||
</voice>
|
||||
</speak>)",
|
||||
voiceShortName,
|
||||
s.data() );
|
||||
|
||||
reply = globalNetworkAccessManager->post( *request, y.data() );
|
||||
qDebug() << "azure tries to speak.";
|
||||
|
||||
connect( reply, &QNetworkReply::finished, this, [ this ]() {
|
||||
qDebug() << "azure gets data.";
|
||||
player->setSourceDevice( reply );
|
||||
player->play();
|
||||
} );
|
||||
|
||||
connect( reply, &QNetworkReply::errorOccurred, this, &AzureService::slotError );
|
||||
connect( reply, &QNetworkReply::sslErrors, this, &AzureService::slotSslErrors );
|
||||
}
|
||||
|
||||
void AzureService::stop() noexcept
|
||||
{
|
||||
this->player->stop();
|
||||
}
|
||||
|
||||
AzureService::~AzureService() = default;
|
||||
|
||||
void AzureService::slotError( QNetworkReply::NetworkError e )
|
||||
{
|
||||
qDebug() << e;
|
||||
}
|
||||
|
||||
void AzureService::slotSslErrors()
|
||||
{
|
||||
emit AzureService::errorOccured( "ssl error" );
|
||||
}
|
||||
|
||||
void AzureService::mediaErrorOccur( QMediaPlayer::Error _, const QString & errorString )
|
||||
{
|
||||
emit AzureService::errorOccured( "media error: " + errorString );
|
||||
}
|
||||
|
||||
void AzureService::mediaStatus( QMediaPlayer::MediaStatus status )
|
||||
{
|
||||
qDebug() << "azure media status " << status;
|
||||
}
|
||||
|
||||
std::optional< AzureConfig > AzureConfig::loadFromFile( const QString & configFilePath )
|
||||
{
|
||||
if ( !QFileInfo::exists( configFilePath ) ) {
|
||||
auto defaultConfig = std::make_unique< AzureConfig >();
|
||||
defaultConfig->apiKey = "b9885138792d4403a8ccf1a34553351d";
|
||||
defaultConfig->region = "eastus";
|
||||
defaultConfig->voiceShortName = "en-CA-ClaraNeural";
|
||||
if ( !saveToFile( configFilePath, *defaultConfig ) ) {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
QFile f( configFilePath );
|
||||
|
||||
if ( !f.open( QFile::ReadOnly ) ) {
|
||||
return {};
|
||||
};
|
||||
|
||||
AzureConfig ret{};
|
||||
|
||||
auto json = QJsonDocument::fromJson( f.readAll() );
|
||||
|
||||
if ( json.isObject() ) {
|
||||
QJsonObject o = json.object();
|
||||
|
||||
if ( const QJsonValue v = o[ "apikey" ]; v.isString() ) {
|
||||
ret.apiKey = v.toString();
|
||||
}
|
||||
else {
|
||||
ret.apiKey = "";
|
||||
}
|
||||
|
||||
if ( const QJsonValue v = o[ "region" ]; v.isString() ) {
|
||||
ret.region = v.toString();
|
||||
}
|
||||
else {
|
||||
ret.region = "";
|
||||
}
|
||||
|
||||
if ( const QJsonValue v = o[ "voiceShortName" ]; v.isString() ) {
|
||||
ret.voiceShortName = v.toString();
|
||||
}
|
||||
else {
|
||||
ret.voiceShortName = "";
|
||||
}
|
||||
}
|
||||
|
||||
return { ret };
|
||||
}
|
||||
|
||||
bool AzureConfig::saveToFile( const QString & configFilePath, const AzureConfig & c )
|
||||
{
|
||||
QJsonDocument doc(
|
||||
QJsonObject( { { "region", c.region }, { "apikey", c.apiKey }, { "voiceShortName", c.voiceShortName } } ) );
|
||||
|
||||
QSaveFile f( configFilePath );
|
||||
f.open( QSaveFile::WriteOnly );
|
||||
f.write( doc.toJson( QJsonDocument::Indented ) );
|
||||
return f.commit();
|
||||
}
|
||||
|
||||
AzureConfigWidget::AzureConfigWidget( QWidget * parent, const QDir & configRootPath ):
|
||||
TTS::ServiceConfigWidget( parent )
|
||||
{
|
||||
azureConfigPath = configRootPath.filePath( AzureSaveFileName );
|
||||
|
||||
auto * form = new QFormLayout( this );
|
||||
|
||||
|
||||
auto config = AzureConfig::loadFromFile( azureConfigPath );
|
||||
|
||||
if ( !config.has_value() ) {
|
||||
throw std::runtime_error( "TODO" );
|
||||
}
|
||||
|
||||
voiceList = new QComboBox( this );
|
||||
|
||||
|
||||
region = new QLineEdit( config->region, this );
|
||||
apiKey = new QLineEdit( config->apiKey, this );
|
||||
this->asyncVoiceListPopulating( config->voiceShortName );
|
||||
|
||||
auto * title = new QLabel( "<b>Azure config</b>", this );
|
||||
|
||||
title->setAlignment( Qt::AlignCenter );
|
||||
form->addRow( title );
|
||||
form->addRow( "Location/Region", region );
|
||||
form->addRow( "API Key", apiKey );
|
||||
form->addRow( "Voice", voiceList );
|
||||
voiceList->setSizePolicy( QSizePolicy::Expanding, QSizePolicy::Maximum );
|
||||
form->setFieldGrowthPolicy( QFormLayout::ExpandingFieldsGrow );
|
||||
apiKey->setMinimumWidth( 400 );
|
||||
|
||||
auto * wrapper = new QVBoxLayout( this );
|
||||
|
||||
wrapper->addLayout( form );
|
||||
wrapper->addStretch();
|
||||
this->setLayout( wrapper );
|
||||
}
|
||||
|
||||
std::optional< std::string > AzureConfigWidget::save() noexcept
|
||||
{
|
||||
|
||||
auto config = std::make_unique< AzureConfig >();
|
||||
config->apiKey = apiKey->text().simplified();
|
||||
config->region = region->text().simplified();
|
||||
config->voiceShortName = voiceList->currentText().simplified();
|
||||
|
||||
|
||||
if ( !AzureConfig::saveToFile( azureConfigPath, *config ) ) {
|
||||
return { "sth is wrong" };
|
||||
}
|
||||
else {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
void AzureConfigWidget::asyncVoiceListPopulating( const QString & autoSelectThisName )
|
||||
{
|
||||
|
||||
voiceListRequest.reset( new QNetworkRequest() );
|
||||
voiceListRequest->setRawHeader( "User-Agent", "WhatEver" );
|
||||
voiceListRequest->setUrl( QUrl( QString( QStringLiteral( "https://%1.%2/voices/list" ) )
|
||||
.arg( this->region->text(), QString::fromUtf8( TTS::hostUrlBody ) ) ) );
|
||||
voiceListRequest->setRawHeader( "Ocp-Apim-Subscription-Key", this->apiKey->text().toLatin1() );
|
||||
|
||||
voiceListReply.reset( globalNetworkAccessManager->get( *voiceListRequest ) );
|
||||
|
||||
connect( voiceListReply.get(), &QNetworkReply::finished, this, [ this, autoSelectThisName ]() {
|
||||
voiceList->clear();
|
||||
auto json = QJsonDocument::fromJson( this->voiceListReply->readAll() );
|
||||
if ( json.isArray() ) {
|
||||
for ( auto && o : json.array() ) {
|
||||
if ( o.isObject() ) {
|
||||
if ( auto r = o.toObject()[ "ShortName" ]; r.isString() ) {
|
||||
if ( auto s = r.toString(); !s.isNull() ) {
|
||||
voiceList->addItem( s );
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( auto i = voiceList->findText( autoSelectThisName ); i != -1 ) {
|
||||
voiceList->setCurrentIndex( i );
|
||||
}
|
||||
} );
|
||||
|
||||
connect( voiceListReply.get(), &QNetworkReply::errorOccurred, this, [ this ]( QNetworkReply::NetworkError e ) {
|
||||
qDebug() << "f";
|
||||
this->voiceList->clear();
|
||||
this->voiceList->addItem( "Failed to retrieve voice list: " + QString::number( e ) );
|
||||
} );
|
||||
}
|
||||
} // namespace TTS
|
62
src/tts/services/azure.hh
Normal file
62
src/tts/services/azure.hh
Normal file
|
@ -0,0 +1,62 @@
|
|||
#pragma once
|
||||
|
||||
#include "tts/service.hh"
|
||||
|
||||
#include <QComboBox>
|
||||
#include <QString>
|
||||
#include <QtNetwork>
|
||||
#include <optional>
|
||||
#include <QMediaPlayer>
|
||||
|
||||
namespace TTS {
|
||||
|
||||
|
||||
class AzureService: public TTS::Service
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
static AzureService * Construct( const QDir & configRootPath );
|
||||
void speak( QUtf8StringView s ) noexcept override;
|
||||
void stop() noexcept override;
|
||||
|
||||
~AzureService() override;
|
||||
|
||||
private:
|
||||
AzureService() = default;
|
||||
bool private_initialize();
|
||||
QNetworkReply * reply;
|
||||
QMediaPlayer * player;
|
||||
QNetworkRequest * request;
|
||||
QString azureConfigFile;
|
||||
std::string voiceShortName;
|
||||
|
||||
private slots:
|
||||
void slotError( QNetworkReply::NetworkError e );
|
||||
void slotSslErrors();
|
||||
|
||||
void mediaErrorOccur( QMediaPlayer::Error error, const QString & errorString );
|
||||
void mediaStatus( QMediaPlayer::MediaStatus status );
|
||||
};
|
||||
|
||||
class AzureConfigWidget: public TTS::ServiceConfigWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit AzureConfigWidget( QWidget * parent, const QDir & configRootPath );
|
||||
|
||||
std::optional< std::string > save() noexcept override;
|
||||
|
||||
private:
|
||||
QString azureConfigPath;
|
||||
QLineEdit * region;
|
||||
QLineEdit * apiKey;
|
||||
std::unique_ptr< QNetworkRequest > voiceListRequest;
|
||||
std::unique_ptr< QNetworkReply > voiceListReply;
|
||||
|
||||
QComboBox * voiceList;
|
||||
|
||||
void asyncVoiceListPopulating( const QString & autoSelectThisName );
|
||||
};
|
||||
} // namespace TTS
|
43
src/tts/services/dummy.hh
Normal file
43
src/tts/services/dummy.hh
Normal file
|
@ -0,0 +1,43 @@
|
|||
#pragma once
|
||||
#include "tts/service.hh"
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QVBoxLayout>
|
||||
|
||||
namespace TTS {
|
||||
class DummyService: public TTS::Service
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
void speak( QUtf8StringView s ) noexcept override
|
||||
{
|
||||
qDebug() << "dummy speaks" << s;
|
||||
};
|
||||
void stop() noexcept override
|
||||
{
|
||||
qDebug() << "dummy stops";
|
||||
};
|
||||
};
|
||||
|
||||
class DummyConfigWidget: public TTS::ServiceConfigWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit DummyConfigWidget( QWidget * parent ):
|
||||
TTS::ServiceConfigWidget( parent )
|
||||
{
|
||||
this->setLayout( new QVBoxLayout );
|
||||
this->layout()->addWidget(
|
||||
new QLabel( R"(This is a sample service. You are welcome to check the source code and add new services. )",
|
||||
parent ) );
|
||||
}
|
||||
|
||||
std::optional< std::string > save() noexcept override
|
||||
{
|
||||
return {};
|
||||
};
|
||||
};
|
||||
|
||||
} // namespace TTS
|
145
src/tts/services/local_command.cc
Normal file
145
src/tts/services/local_command.cc
Normal file
|
@ -0,0 +1,145 @@
|
|||
#include "tts/services/local_command.hh"
|
||||
#include <QFile>
|
||||
#include <QSaveFile>
|
||||
#include <toml++/toml.hpp>
|
||||
|
||||
namespace TTS {
|
||||
|
||||
|
||||
static const char * LocalCommandSaveFileName = "local_command.toml";
|
||||
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
/// @brief try read cmd or create a default one
|
||||
/// @param path
|
||||
/// @return if true -> QString is wanted string, else errorString. (Poor man's std::expected)
|
||||
std::tuple< bool, QString > getLocalCommandConfigFromFile( const QString & path ) noexcept
|
||||
{
|
||||
if ( !QFileInfo::exists( path ) ) {
|
||||
QSaveFile f( path );
|
||||
f.open( QSaveFile::WriteOnly );
|
||||
|
||||
auto tbl = toml::table{
|
||||
{ "cmd",
|
||||
R"raw(pwsh.exe -Command $(New-Object System.Speech.Synthesis.SpeechSynthesizer).speak('%GDSENTENCE%'))raw" },
|
||||
};
|
||||
|
||||
std::stringstream out;
|
||||
out << tbl;
|
||||
|
||||
f.write( QByteArray::fromStdString( out.str() ) );
|
||||
if ( f.commit() == false ) {
|
||||
throw std::runtime_error( "Cannot write to file." );
|
||||
}
|
||||
}
|
||||
|
||||
toml::table tbl;
|
||||
try {
|
||||
tbl = toml::parse_file( path.toStdString() );
|
||||
}
|
||||
catch ( const toml::parse_error & err ) {
|
||||
return { false, err.what() };
|
||||
}
|
||||
|
||||
auto cmd = tbl[ "cmd" ].value< std::string >();
|
||||
|
||||
if ( cmd.has_value() ) {
|
||||
return {
|
||||
true,
|
||||
QString::fromStdString( cmd.value() ),
|
||||
};
|
||||
}
|
||||
else {
|
||||
return { true, "" };
|
||||
}
|
||||
};
|
||||
} // namespace
|
||||
|
||||
|
||||
LocalCommandService::LocalCommandService( const QDir & configRootPath )
|
||||
{
|
||||
|
||||
this->configFilePath = configRootPath.filePath( LocalCommandSaveFileName );
|
||||
}
|
||||
|
||||
std::optional< std::string > LocalCommandService::loadCommandFromConfigFile()
|
||||
{
|
||||
auto [ status, str ] = getLocalCommandConfigFromFile( this->configFilePath );
|
||||
if ( status == true ) {
|
||||
this->command = str;
|
||||
return {};
|
||||
}
|
||||
else {
|
||||
return { str.toStdString() };
|
||||
}
|
||||
}
|
||||
|
||||
LocalCommandService::~LocalCommandService()
|
||||
{
|
||||
process->disconnect( this ); // Prevent innocent error at program exit, which is considered as error.
|
||||
}
|
||||
|
||||
void LocalCommandService::speak( QUtf8StringView s ) noexcept
|
||||
{
|
||||
process.reset( new QProcess( this ) );
|
||||
QString cmd_to_be_executed = command;
|
||||
cmd_to_be_executed.replace( "%GDSENTENCE%", s.toString() );
|
||||
qDebug() << "local command speaking: " << cmd_to_be_executed;
|
||||
process->startCommand( cmd_to_be_executed );
|
||||
// TODO: handle errors of processes.
|
||||
connect( process.get(), &QProcess::errorOccurred, this, [ this ]( QProcess::ProcessError error ) {
|
||||
emit TTS::Service::errorOccured( "Process failed to execute due to QProcess::ProcessError" );
|
||||
} );
|
||||
}
|
||||
|
||||
void LocalCommandService::stop() noexcept
|
||||
{
|
||||
process.reset(); // deleter of QProcess also kills the process
|
||||
}
|
||||
|
||||
|
||||
LocalCommandConfigWidget::LocalCommandConfigWidget( QWidget * parent, const QDir & configRootPath ):
|
||||
TTS::ServiceConfigWidget( parent )
|
||||
{
|
||||
auto * layout = new QVBoxLayout( this );
|
||||
layout->addWidget( new QLabel( R"(Set command)", parent ) );
|
||||
|
||||
commandLineEdit = new QLineEdit( this );
|
||||
layout->addWidget( commandLineEdit );
|
||||
layout->addStretch();
|
||||
|
||||
this->configFilePath = configRootPath.filePath( LocalCommandSaveFileName );
|
||||
|
||||
auto [ status, str ] = getLocalCommandConfigFromFile( this->configFilePath );
|
||||
|
||||
if ( status == true ) {
|
||||
commandLineEdit->setText( str );
|
||||
}
|
||||
else {
|
||||
// do nothing.
|
||||
}
|
||||
}
|
||||
|
||||
std::optional< std::string > LocalCommandConfigWidget::save() noexcept
|
||||
{
|
||||
|
||||
QSaveFile f( this->configFilePath );
|
||||
f.open( QSaveFile::WriteOnly );
|
||||
|
||||
auto tbl = toml::table{
|
||||
{ "cmd", commandLineEdit->text().simplified().toStdString() },
|
||||
};
|
||||
|
||||
std::stringstream out;
|
||||
out << tbl;
|
||||
|
||||
f.write( QByteArray::fromStdString( out.str() ) );
|
||||
if ( f.commit() == false ) {
|
||||
return { "Cannot write to file." };
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
} // namespace TTS
|
51
src/tts/services/local_command.hh
Normal file
51
src/tts/services/local_command.hh
Normal file
|
@ -0,0 +1,51 @@
|
|||
#pragma once
|
||||
#include "tts/service.hh"
|
||||
#include <QLabel>
|
||||
#include <QLayout>
|
||||
#include <QVBoxLayout>
|
||||
#include <QDir>
|
||||
#include <QProcess>
|
||||
#include <QLineEdit>
|
||||
|
||||
namespace TTS {
|
||||
|
||||
|
||||
class LocalCommandService: public TTS::Service
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit LocalCommandService( const QDir & configRootPath );
|
||||
|
||||
/// @brief
|
||||
/// @return failure message if any
|
||||
std::optional< std::string > loadCommandFromConfigFile();
|
||||
~LocalCommandService();
|
||||
|
||||
void speak( QUtf8StringView s ) noexcept override;
|
||||
void stop() noexcept override;
|
||||
signals:
|
||||
|
||||
|
||||
private:
|
||||
QString configFilePath;
|
||||
QString command;
|
||||
std::unique_ptr< QProcess > process;
|
||||
};
|
||||
|
||||
class LocalCommandConfigWidget: public TTS::ServiceConfigWidget
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit LocalCommandConfigWidget( QWidget * parent, const QDir & configRootPath );
|
||||
|
||||
std::optional< std::string > save() noexcept override;
|
||||
|
||||
private:
|
||||
QLineEdit * commandLineEdit;
|
||||
QString configFilePath; // Don't use replace on this
|
||||
};
|
||||
|
||||
|
||||
} // namespace TTS
|
47
src/tts/single_service_controller.cc
Normal file
47
src/tts/single_service_controller.cc
Normal file
|
@ -0,0 +1,47 @@
|
|||
#include "tts/single_service_controller.hh"
|
||||
#include "config_file_main.hh"
|
||||
#include "tts/services/azure.hh"
|
||||
#include "tts/services/dummy.hh"
|
||||
#include "tts/services/local_command.hh"
|
||||
#include "tts/error_dialog.hh"
|
||||
|
||||
namespace TTS {
|
||||
|
||||
SingleServiceController::SingleServiceController( const QString & configPath )
|
||||
{
|
||||
configRootDir = QDir( configPath );
|
||||
configRootDir.mkpath( QStringLiteral( "ctts" ) );
|
||||
configRootDir.cd( QStringLiteral( "ctts" ) );
|
||||
currentService.reset();
|
||||
}
|
||||
|
||||
void SingleServiceController::reload()
|
||||
{
|
||||
QString service_name = get_service_name_from_path( this->configRootDir );
|
||||
if ( service_name == "azure" ) {
|
||||
currentService.reset( TTS::AzureService::Construct( this->configRootDir ) );
|
||||
}
|
||||
else if ( service_name == "local_command" ) {
|
||||
auto * s = new TTS::LocalCommandService( this->configRootDir );
|
||||
s->loadCommandFromConfigFile(); // TODO:: error unhandled.
|
||||
currentService.reset( s );
|
||||
}
|
||||
else {
|
||||
currentService.reset( new TTS::DummyService() );
|
||||
}
|
||||
|
||||
connect( currentService.get(), &Service::errorOccured, []( const QString & errorString ) {
|
||||
TTS::reportError( errorString );
|
||||
} );
|
||||
}
|
||||
|
||||
void SingleServiceController::speak( const QString & text )
|
||||
{
|
||||
|
||||
if ( !currentService ) {
|
||||
this->reload();
|
||||
}
|
||||
currentService->speak( text.toStdString() );
|
||||
}
|
||||
|
||||
} // namespace TTS
|
26
src/tts/single_service_controller.hh
Normal file
26
src/tts/single_service_controller.hh
Normal file
|
@ -0,0 +1,26 @@
|
|||
#pragma once
|
||||
#include "tts/service.hh"
|
||||
#include <QDir>
|
||||
|
||||
namespace TTS {
|
||||
|
||||
/// @brief Manage the life time of one single service, which can be reloaded to other services.
|
||||
class SingleServiceController: public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
|
||||
public:
|
||||
explicit SingleServiceController( const QString & configPath );
|
||||
|
||||
public slots:
|
||||
void speak( const QString & text );
|
||||
void reload(); // TODO handle error
|
||||
|
||||
|
||||
private:
|
||||
QDir configRootDir;
|
||||
std::unique_ptr< Service > currentService;
|
||||
};
|
||||
|
||||
|
||||
} // namespace TTS
|
|
@ -1475,6 +1475,7 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
|
|||
QAction * openImageAction = nullptr;
|
||||
QAction * saveSoundAction = nullptr;
|
||||
QAction * saveBookmark = nullptr;
|
||||
QAction * prounceSelectionAction = nullptr;
|
||||
|
||||
#if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) )
|
||||
const QWebEngineContextMenuData * menuData = &( r->contextMenuData() );
|
||||
|
@ -1544,6 +1545,9 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
|
|||
|
||||
sendWordToInputLineAction = new QAction( tr( "Send \"%1\" to input line" ).arg( text ), &menu );
|
||||
menu.addAction( sendWordToInputLineAction );
|
||||
|
||||
prounceSelectionAction = new QAction( "Speak selection", &menu );
|
||||
menu.addAction( prounceSelectionAction );
|
||||
}
|
||||
|
||||
addWordToHistoryAction = new QAction( tr( "&Add \"%1\" to history" ).arg( text ), &menu );
|
||||
|
@ -1679,6 +1683,9 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
|
|||
emit showDefinitionInNewTab( selectedText, getGroup( webview->url() ), getCurrentArticle(), Contexts() );
|
||||
else if ( !popupView && result == lookupSelectionNewTabGr && currentGroupId )
|
||||
emit showDefinitionInNewTab( selectedText, currentGroupId, QString(), Contexts() );
|
||||
else if ( !popupView && result == prounceSelectionAction ) {
|
||||
emit prounceSelection( selectedText );
|
||||
}
|
||||
else if ( result == saveImageAction || result == saveSoundAction ) {
|
||||
QUrl url = ( result == saveImageAction ) ? imageUrl : targetUrl;
|
||||
QString savePath;
|
||||
|
|
|
@ -268,6 +268,8 @@ signals:
|
|||
QString const & fromArticle,
|
||||
Contexts const & contexts );
|
||||
|
||||
void prounceSelection( const QString & selectionText );
|
||||
|
||||
/// Put translated word into history
|
||||
void sendWordToHistory( QString const & word );
|
||||
|
||||
|
|
|
@ -61,6 +61,9 @@
|
|||
#include <windows.h>
|
||||
#endif
|
||||
|
||||
#include "tts/config_window.hh"
|
||||
|
||||
|
||||
#include <QWebEngineSettings>
|
||||
#include <QProxyStyle>
|
||||
|
||||
|
@ -170,7 +173,8 @@ MainWindow::MainWindow( Config::Class & cfg_ ):
|
|||
ftsIndexing( dictionaries ),
|
||||
ftsDlg( nullptr ),
|
||||
starIcon( ":/icons/star.svg" ),
|
||||
blueStarIcon( ":/icons/star_blue.svg" )
|
||||
blueStarIcon( ":/icons/star_blue.svg" ),
|
||||
ttsServiceController( new TTS::SingleServiceController( Config::getConfigDir() ) )
|
||||
{
|
||||
if ( QThreadPool::globalInstance()->maxThreadCount() < MIN_THREAD_COUNT )
|
||||
QThreadPool::globalInstance()->setMaxThreadCount( MIN_THREAD_COUNT );
|
||||
|
@ -639,6 +643,16 @@ MainWindow::MainWindow( Config::Class & cfg_ ):
|
|||
|
||||
connect( ui.dictionaries, &QAction::triggered, this, &MainWindow::editDictionaries );
|
||||
|
||||
connect( ui.menuTextToSpeech, &QAction::triggered, this, [ this ] {
|
||||
auto * ttsConfigWindow = new TTS::ConfigWindow( this, Config::getConfigDir() );
|
||||
ttsConfigWindow->show();
|
||||
connect( ttsConfigWindow,
|
||||
&TTS::ConfigWindow::service_changed,
|
||||
this->ttsServiceController.get(),
|
||||
&TTS::SingleServiceController::reload );
|
||||
} );
|
||||
|
||||
|
||||
connect( ui.preferences, &QAction::triggered, this, &MainWindow::editPreferences );
|
||||
|
||||
connect( ui.visitHomepage, &QAction::triggered, this, &MainWindow::visitHomepage );
|
||||
|
@ -1779,6 +1793,8 @@ ArticleView * MainWindow::createNewTab( bool switchToIt, QString const & name )
|
|||
|
||||
connect( view, &ArticleView::showDefinitionInNewTab, this, &MainWindow::showDefinitionInNewTab );
|
||||
|
||||
connect( view, &ArticleView::prounceSelection, ttsServiceController.get(), &TTS::SingleServiceController::speak );
|
||||
|
||||
connect( view, &ArticleView::typingEvent, this, &MainWindow::typingEvent );
|
||||
|
||||
connect( view, &ArticleView::activeArticleChanged, this, &MainWindow::activeArticleChanged );
|
||||
|
|
|
@ -29,6 +29,7 @@
|
|||
#include "dictheadwords.hh"
|
||||
#include "fulltextsearch.hh"
|
||||
#include "base_type.hh"
|
||||
#include "tts/single_service_controller.hh"
|
||||
|
||||
#include "hotkeywrapper.hh"
|
||||
#include "resourceschemehandler.hh"
|
||||
|
@ -142,6 +143,8 @@ private:
|
|||
// in a separate thread
|
||||
AudioPlayerFactory audioPlayerFactory;
|
||||
|
||||
QScopedPointer< TTS::SingleServiceController > ttsServiceController;
|
||||
|
||||
//current active translateLine;
|
||||
QLineEdit * translateLine;
|
||||
|
||||
|
|
|
@ -70,7 +70,7 @@
|
|||
<x>0</x>
|
||||
<y>0</y>
|
||||
<width>653</width>
|
||||
<height>21</height>
|
||||
<height>22</height>
|
||||
</rect>
|
||||
</property>
|
||||
<widget class="QMenu" name="menuFile">
|
||||
|
@ -95,6 +95,7 @@
|
|||
<string>&Edit</string>
|
||||
</property>
|
||||
<addaction name="dictionaries"/>
|
||||
<addaction name="menuTextToSpeech"/>
|
||||
<addaction name="preferences"/>
|
||||
</widget>
|
||||
<widget class="QMenu" name="menu_Help">
|
||||
|
@ -581,6 +582,11 @@
|
|||
<string>Export to list</string>
|
||||
</property>
|
||||
</action>
|
||||
<action name="menuTextToSpeech">
|
||||
<property name="text">
|
||||
<string>Text to Speech</string>
|
||||
</property>
|
||||
</action>
|
||||
</widget>
|
||||
<customwidgets>
|
||||
<customwidget>
|
||||
|
|
Loading…
Reference in a new issue