This commit is contained in:
shenleban tongying 2024-07-16 21:03:04 -04:00
parent 2b98b506fe
commit ce5b9c4fb3
24 changed files with 1059 additions and 4 deletions

6
.gitignore vendored
View file

@ -33,7 +33,7 @@ GoldenDict.xcodeproj/
*.suo *.suo
*.vcxproj.user *.vcxproj.user
/.idea /.idea
/.vs .vs
/.vscode /.vscode
/.qtc_clangd /.qtc_clangd
@ -56,3 +56,7 @@ GoldenDict_resource.rc
*.orig *.orig
node_modules node_modules
# tts testing files
*.ogg
*.mp3

View file

@ -0,0 +1,4 @@
#pragma once
#include <QNetworkAccessManager>
Q_APPLICATION_STATIC( QNetworkAccessManager, globalNetworkAccessManager )

54
src/tts/README.md Normal file
View 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.

View 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

View 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
View 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
View 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

View file

@ -0,0 +1 @@
Files to test various services.

View 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>

View 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
View 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
View 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
View 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
View 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
View 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

View 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

View 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

View 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

View 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

View file

@ -1475,6 +1475,7 @@ void ArticleView::contextMenuRequested( QPoint const & pos )
QAction * openImageAction = nullptr; QAction * openImageAction = nullptr;
QAction * saveSoundAction = nullptr; QAction * saveSoundAction = nullptr;
QAction * saveBookmark = nullptr; QAction * saveBookmark = nullptr;
QAction * prounceSelectionAction = nullptr;
#if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) ) #if ( QT_VERSION < QT_VERSION_CHECK( 6, 0, 0 ) )
const QWebEngineContextMenuData * menuData = &( r->contextMenuData() ); 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 ); sendWordToInputLineAction = new QAction( tr( "Send \"%1\" to input line" ).arg( text ), &menu );
menu.addAction( sendWordToInputLineAction ); menu.addAction( sendWordToInputLineAction );
prounceSelectionAction = new QAction( "Speak selection", &menu );
menu.addAction( prounceSelectionAction );
} }
addWordToHistoryAction = new QAction( tr( "&Add \"%1\" to history" ).arg( text ), &menu ); 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() ); emit showDefinitionInNewTab( selectedText, getGroup( webview->url() ), getCurrentArticle(), Contexts() );
else if ( !popupView && result == lookupSelectionNewTabGr && currentGroupId ) else if ( !popupView && result == lookupSelectionNewTabGr && currentGroupId )
emit showDefinitionInNewTab( selectedText, currentGroupId, QString(), Contexts() ); emit showDefinitionInNewTab( selectedText, currentGroupId, QString(), Contexts() );
else if ( !popupView && result == prounceSelectionAction ) {
emit prounceSelection( selectedText );
}
else if ( result == saveImageAction || result == saveSoundAction ) { else if ( result == saveImageAction || result == saveSoundAction ) {
QUrl url = ( result == saveImageAction ) ? imageUrl : targetUrl; QUrl url = ( result == saveImageAction ) ? imageUrl : targetUrl;
QString savePath; QString savePath;

View file

@ -268,6 +268,8 @@ signals:
QString const & fromArticle, QString const & fromArticle,
Contexts const & contexts ); Contexts const & contexts );
void prounceSelection( const QString & selectionText );
/// Put translated word into history /// Put translated word into history
void sendWordToHistory( QString const & word ); void sendWordToHistory( QString const & word );

View file

@ -61,6 +61,9 @@
#include <windows.h> #include <windows.h>
#endif #endif
#include "tts/config_window.hh"
#include <QWebEngineSettings> #include <QWebEngineSettings>
#include <QProxyStyle> #include <QProxyStyle>
@ -170,7 +173,8 @@ MainWindow::MainWindow( Config::Class & cfg_ ):
ftsIndexing( dictionaries ), ftsIndexing( dictionaries ),
ftsDlg( nullptr ), ftsDlg( nullptr ),
starIcon( ":/icons/star.svg" ), 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 ) if ( QThreadPool::globalInstance()->maxThreadCount() < MIN_THREAD_COUNT )
QThreadPool::globalInstance()->setMaxThreadCount( 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.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.preferences, &QAction::triggered, this, &MainWindow::editPreferences );
connect( ui.visitHomepage, &QAction::triggered, this, &MainWindow::visitHomepage ); 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::showDefinitionInNewTab, this, &MainWindow::showDefinitionInNewTab );
connect( view, &ArticleView::prounceSelection, ttsServiceController.get(), &TTS::SingleServiceController::speak );
connect( view, &ArticleView::typingEvent, this, &MainWindow::typingEvent ); connect( view, &ArticleView::typingEvent, this, &MainWindow::typingEvent );
connect( view, &ArticleView::activeArticleChanged, this, &MainWindow::activeArticleChanged ); connect( view, &ArticleView::activeArticleChanged, this, &MainWindow::activeArticleChanged );

View file

@ -29,6 +29,7 @@
#include "dictheadwords.hh" #include "dictheadwords.hh"
#include "fulltextsearch.hh" #include "fulltextsearch.hh"
#include "base_type.hh" #include "base_type.hh"
#include "tts/single_service_controller.hh"
#include "hotkeywrapper.hh" #include "hotkeywrapper.hh"
#include "resourceschemehandler.hh" #include "resourceschemehandler.hh"
@ -142,6 +143,8 @@ private:
// in a separate thread // in a separate thread
AudioPlayerFactory audioPlayerFactory; AudioPlayerFactory audioPlayerFactory;
QScopedPointer< TTS::SingleServiceController > ttsServiceController;
//current active translateLine; //current active translateLine;
QLineEdit * translateLine; QLineEdit * translateLine;

View file

@ -70,7 +70,7 @@
<x>0</x> <x>0</x>
<y>0</y> <y>0</y>
<width>653</width> <width>653</width>
<height>21</height> <height>22</height>
</rect> </rect>
</property> </property>
<widget class="QMenu" name="menuFile"> <widget class="QMenu" name="menuFile">
@ -95,6 +95,7 @@
<string>&amp;Edit</string> <string>&amp;Edit</string>
</property> </property>
<addaction name="dictionaries"/> <addaction name="dictionaries"/>
<addaction name="menuTextToSpeech"/>
<addaction name="preferences"/> <addaction name="preferences"/>
</widget> </widget>
<widget class="QMenu" name="menu_Help"> <widget class="QMenu" name="menu_Help">
@ -581,6 +582,11 @@
<string>Export to list</string> <string>Export to list</string>
</property> </property>
</action> </action>
<action name="menuTextToSpeech">
<property name="text">
<string>Text to Speech</string>
</property>
</action>
</widget> </widget>
<customwidgets> <customwidgets>
<customwidget> <customwidget>