diff --git a/.gitignore b/.gitignore index 4c9964d3..180968d7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,7 +33,7 @@ GoldenDict.xcodeproj/ *.suo *.vcxproj.user /.idea -/.vs +.vs /.vscode /.qtc_clangd @@ -55,4 +55,8 @@ GoldenDict_resource.rc *.TMP *.orig -node_modules \ No newline at end of file +node_modules + +# tts testing files +*.ogg +*.mp3 \ No newline at end of file diff --git a/src/global_network_access_manager.hh b/src/global_network_access_manager.hh new file mode 100644 index 00000000..c01290cd --- /dev/null +++ b/src/global_network_access_manager.hh @@ -0,0 +1,4 @@ +#pragma once + +#include +Q_APPLICATION_STATIC( QNetworkAccessManager, globalNetworkAccessManager ) \ No newline at end of file diff --git a/src/tts/README.md b/src/tts/README.md new file mode 100644 index 00000000..67e824f1 --- /dev/null +++ b/src/tts/README.md @@ -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. \ No newline at end of file diff --git a/src/tts/config_file_main.cc b/src/tts/config_file_main.cc new file mode 100644 index 00000000..ec5565a2 --- /dev/null +++ b/src/tts/config_file_main.cc @@ -0,0 +1,35 @@ +#include "config_file_main.hh" + +#include +#include + + +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 \ No newline at end of file diff --git a/src/tts/config_file_main.hh b/src/tts/config_file_main.hh new file mode 100644 index 00000000..262fe32c --- /dev/null +++ b/src/tts/config_file_main.hh @@ -0,0 +1,7 @@ +#pragma once +#include + +namespace TTS { +QString get_service_name_from_path( const QDir & configRootPath ); +void save_service_name_to_path( const QDir & configPath, QUtf8StringView serviceName ); +} // namespace TTS \ No newline at end of file diff --git a/src/tts/config_window.cc b/src/tts/config_window.cc new file mode 100644 index 00000000..7be5f9d7 --- /dev/null +++ b/src/tts/config_window.cc @@ -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 +#include +#include +#include +#include +#include + +#include + +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"(Experimental feature. The default API key may stop working at anytime. Feedback & Coding help are welcomed. )", + 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 \ No newline at end of file diff --git a/src/tts/config_window.hh b/src/tts/config_window.hh new file mode 100644 index 00000000..df168450 --- /dev/null +++ b/src/tts/config_window.hh @@ -0,0 +1,42 @@ +#pragma once + +#include "tts/services/azure.hh" +#include +#include +#include +#include + +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 \ No newline at end of file diff --git a/src/tts/dev_helpers/README.md b/src/tts/dev_helpers/README.md new file mode 100644 index 00000000..dcb25194 --- /dev/null +++ b/src/tts/dev_helpers/README.md @@ -0,0 +1 @@ +Files to test various services. \ No newline at end of file diff --git a/src/tts/dev_helpers/voice.hurl b/src/tts/dev_helpers/voice.hurl new file mode 100644 index 00000000..0fdd59ac --- /dev/null +++ b/src/tts/dev_helpers/voice.hurl @@ -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 + + + hello world + + diff --git a/src/tts/dev_helpers/voicelist.hurl b/src/tts/dev_helpers/voicelist.hurl new file mode 100644 index 00000000..ee90b3fc --- /dev/null +++ b/src/tts/dev_helpers/voicelist.hurl @@ -0,0 +1,3 @@ +GET https://eastus.tts.speech.microsoft.com/cognitiveservices/voices/list + +Ocp-Apim-Subscription-Key: b9885138792d4403a8ccf1a34553351d diff --git a/src/tts/error_dialog.hh b/src/tts/error_dialog.hh new file mode 100644 index 00000000..b5424d1b --- /dev/null +++ b/src/tts/error_dialog.hh @@ -0,0 +1,13 @@ +#pragma once + +#include + +namespace TTS { +void reportError( const QString & errorString ) +{ + QMessageBox msgBox{}; + msgBox.setText( "Text to speech failed: " % errorString ); + msgBox.setIcon( QMessageBox::Warning ); + msgBox.exec(); +} +} // namespace TTS \ No newline at end of file diff --git a/src/tts/service.hh b/src/tts/service.hh new file mode 100644 index 00000000..6199b4a4 --- /dev/null +++ b/src/tts/service.hh @@ -0,0 +1,44 @@ +#pragma once + +#include +#include + +/* + * + * 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 \ No newline at end of file diff --git a/src/tts/services/azure.cc b/src/tts/services/azure.cc new file mode 100644 index 00000000..04660949 --- /dev/null +++ b/src/tts/services/azure.cc @@ -0,0 +1,276 @@ +#include "tts/services/azure.hh" +#include "global_network_access_manager.hh" + +#include +#include +#include +#include +#include + +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"( + + {} + +)", + 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( "Azure config", 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 \ No newline at end of file diff --git a/src/tts/services/azure.hh b/src/tts/services/azure.hh new file mode 100644 index 00000000..37ff87ac --- /dev/null +++ b/src/tts/services/azure.hh @@ -0,0 +1,62 @@ +#pragma once + +#include "tts/service.hh" + +#include +#include +#include +#include +#include + +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 \ No newline at end of file diff --git a/src/tts/services/dummy.hh b/src/tts/services/dummy.hh new file mode 100644 index 00000000..5330536a --- /dev/null +++ b/src/tts/services/dummy.hh @@ -0,0 +1,43 @@ +#pragma once +#include "tts/service.hh" +#include +#include +#include + +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 diff --git a/src/tts/services/local_command.cc b/src/tts/services/local_command.cc new file mode 100644 index 00000000..e6194add --- /dev/null +++ b/src/tts/services/local_command.cc @@ -0,0 +1,145 @@ +#include "tts/services/local_command.hh" +#include +#include +#include + +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 \ No newline at end of file diff --git a/src/tts/services/local_command.hh b/src/tts/services/local_command.hh new file mode 100644 index 00000000..bf492c76 --- /dev/null +++ b/src/tts/services/local_command.hh @@ -0,0 +1,51 @@ +#pragma once +#include "tts/service.hh" +#include +#include +#include +#include +#include +#include + +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 diff --git a/src/tts/single_service_controller.cc b/src/tts/single_service_controller.cc new file mode 100644 index 00000000..7fc1fa2e --- /dev/null +++ b/src/tts/single_service_controller.cc @@ -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 \ No newline at end of file diff --git a/src/tts/single_service_controller.hh b/src/tts/single_service_controller.hh new file mode 100644 index 00000000..9dacb0d8 --- /dev/null +++ b/src/tts/single_service_controller.hh @@ -0,0 +1,26 @@ +#pragma once +#include "tts/service.hh" +#include + +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 \ No newline at end of file diff --git a/src/ui/articleview.cc b/src/ui/articleview.cc index 45722dce..a60bfe63 100644 --- a/src/ui/articleview.cc +++ b/src/ui/articleview.cc @@ -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; diff --git a/src/ui/articleview.hh b/src/ui/articleview.hh index 2aa82f8f..bea81622 100644 --- a/src/ui/articleview.hh +++ b/src/ui/articleview.hh @@ -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 ); diff --git a/src/ui/mainwindow.cc b/src/ui/mainwindow.cc index d9aa581d..08d99645 100644 --- a/src/ui/mainwindow.cc +++ b/src/ui/mainwindow.cc @@ -61,6 +61,9 @@ #include #endif +#include "tts/config_window.hh" + + #include #include @@ -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 ); diff --git a/src/ui/mainwindow.hh b/src/ui/mainwindow.hh index 66d4a636..8a9a8269 100644 --- a/src/ui/mainwindow.hh +++ b/src/ui/mainwindow.hh @@ -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; diff --git a/src/ui/mainwindow.ui b/src/ui/mainwindow.ui index 056fbc21..70f15ce3 100644 --- a/src/ui/mainwindow.ui +++ b/src/ui/mainwindow.ui @@ -70,7 +70,7 @@ 0 0 653 - 21 + 22 @@ -95,6 +95,7 @@ &Edit + @@ -581,6 +582,11 @@ Export to list + + + Text to Speech + +