diff --git a/CMakeLists.txt b/CMakeLists.txt --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -8,6 +8,12 @@ set(KF5_MIN_VERSION "5.62.0") set(INSTALL_SDDM_THEME TRUE) find_package(Qt5 ${QT_MIN_VERSION} CONFIG REQUIRED COMPONENTS Widgets Quick QuickWidgets Concurrent Test Network) +find_package(Qt5 ${QT_REQUIRED_VERSION} OPTIONAL_COMPONENTS TextToSpeech) +if (NOT Qt5TextToSpeech_FOUND) + message(STATUS "Qt5TextToSpeech not found, speech features will be disabled") +else() + add_definitions(-DHAVE_SPEECH) +endif() find_package(ECM ${KF5_MIN_VERSION} REQUIRED NO_MODULE) set(CMAKE_MODULE_PATH ${ECM_MODULE_PATH}) diff --git a/klipper/CMakeLists.txt b/klipper/CMakeLists.txt --- a/klipper/CMakeLists.txt +++ b/klipper/CMakeLists.txt @@ -20,6 +20,11 @@ clipcommandprocess.cpp ) +if (Qt5TextToSpeech_FOUND) + set(libklipper_common_SRCS ${libklipper_common_SRCS} + tts.cpp) +endif() + ecm_qt_declare_logging_category(libklipper_common_SRCS HEADER klipper_debug.h IDENTIFIER KLIPPER_LOG CATEGORY_NAME org.kde.klipper) find_package(KF5Prison ${KF5_MIN_VERSION}) @@ -56,6 +61,9 @@ KF5::XmlGui ${ZLIB_LIBRARY} ) +if (Qt5TextToSpeech_FOUND) + target_link_libraries(kdeinit_klipper Qt5::TextToSpeech) +endif() if (X11_FOUND) target_link_libraries(kdeinit_klipper XCB::XCB Qt5::X11Extras) endif() @@ -91,6 +99,9 @@ KF5::XmlGui # KActionCollection ${ZLIB_LIBRARY} ) +if (Qt5TextToSpeech_FOUND) + target_link_libraries(plasma_engine_clipboard Qt5::TextToSpeech) +endif() if (X11_FOUND) target_link_libraries(plasma_engine_clipboard XCB::XCB Qt5::X11Extras) endif() diff --git a/klipper/configdialog.cpp b/klipper/configdialog.cpp --- a/klipper/configdialog.cpp +++ b/klipper/configdialog.cpp @@ -25,6 +25,10 @@ #include #include +#ifdef HAVE_SPEECH +#include +#endif + #include "klipper_debug.h" #include "klipper.h" @@ -37,6 +41,17 @@ m_ui.kcfg_TimeoutForActionPopups->setSuffix(ki18np(" second", " seconds")); m_ui.kcfg_MaxClipItems->setSuffix(ki18np(" entry", " entries")); +#ifdef HAVE_SPEECH + // Populate tts engines + const QStringList engines = QTextToSpeech::availableEngines(); + for (const QString &engine: engines) { + m_ui.kcfg_ttsEngine->addItem (engine); + } + m_ui.kcfg_ttsEngine->setProperty("kcfg_property", QByteArray("currentText")); +#else + m_ui.ttsEngineLabel->hide(); + m_ui.kcfg_ttsEngine->hide(); +#endif } void GeneralWidget::updateWidgets() diff --git a/klipper/generalconfig.ui b/klipper/generalconfig.ui --- a/klipper/generalconfig.ui +++ b/klipper/generalconfig.ui @@ -90,6 +90,16 @@ + + + + Speech engine: + + + + + + diff --git a/klipper/klipper.h b/klipper/klipper.h --- a/klipper/klipper.h +++ b/klipper/klipper.h @@ -2,6 +2,7 @@ Copyright (C) by Andrew Stanley-Jones Copyright (C) 2004 Esben Mose Hansen Copyright (C) 2008 by Dmitry Suzdalev + Copyright (C) 2019 by Jeremy Whiting This program is free software; you can redistribute it and/or modify it under the terms of the GNU General Public @@ -42,6 +43,10 @@ class HistoryItem; class KNotification; +#ifdef HAVE_SPEECH +class KlipperTTS; +#endif + enum class KlipperMode { Standalone, DataEngine @@ -62,6 +67,12 @@ Q_SCRIPTABLE QString getClipboardHistoryItem(int i); Q_SCRIPTABLE void showKlipperPopupMenu(); Q_SCRIPTABLE void showKlipperManuallyInvokeActionMenu(); +#ifdef HAVE_SPEECH + Q_SCRIPTABLE void speakTextSelection(); + Q_SCRIPTABLE void speakClipboardContents(); + Q_SCRIPTABLE void stopSpeaking(); + Q_SCRIPTABLE void pauseResumeSpeaking(); +#endif public: Klipper(QObject* parent, const KSharedConfigPtr& config, KlipperMode mode = KlipperMode::Standalone); @@ -180,6 +191,13 @@ QAction* m_cyclePrevAction; QAction* m_showOnMousePos; +#ifdef HAVE_SPEECH + QAction* m_speakTextSelectionAction; + QAction* m_speakClipboardAction; + QAction* m_stopSpeakingAction; + QAction* m_pauseResumeSpeakingAction; +#endif + bool m_bKeepContents :1; bool m_bURLGrabber :1; bool m_bReplayActionInHistory :1; @@ -212,6 +230,9 @@ KlipperMode m_mode; QTimer *m_saveFileTimer = nullptr; QPointer m_notification; +#ifdef HAVE_SPEECH + KlipperTTS *m_tts; +#endif }; #endif diff --git a/klipper/klipper.cpp b/klipper/klipper.cpp --- a/klipper/klipper.cpp +++ b/klipper/klipper.cpp @@ -56,6 +56,10 @@ #include #endif +#ifdef HAVE_SPEECH +#include "tts.h" +#endif + #include #if HAVE_X11 #include @@ -94,6 +98,9 @@ , m_config( config ) , m_pendingContentsCheck( false ) , m_mode(mode) +#ifdef HAVE_SPEECH + , m_tts(new KlipperTTS(this)) +#endif { if (m_mode == KlipperMode::Standalone) { setenv("KSNI_NO_DBUSMENU", "1", 1); @@ -193,6 +200,30 @@ ); #endif +#ifdef HAVE_SPEECH + m_speakTextSelectionAction = m_collection->addAction(QStringLiteral("speak-text-selection")); + m_speakTextSelectionAction->setText(i18n("Speak text selection")); + // Use meta-escape like macos option-escape for speaking selection + KGlobalAccel::setGlobalShortcut(m_speakTextSelectionAction, QKeySequence(Qt::META + Qt::Key_Escape)); + connect(m_speakTextSelectionAction, &QAction::triggered, this, &Klipper::speakTextSelection); + + m_speakClipboardAction = m_collection->addAction(QStringLiteral("speak-clipboard")); + m_speakClipboardAction->setText(i18n("Speak clipboard contents")); + // Use shift-meta-escape for speaking clipboard instead + KGlobalAccel::setGlobalShortcut(m_speakClipboardAction, QKeySequence(Qt::META + Qt::SHIFT + Qt::Key_Escape)); + connect(m_speakClipboardAction, &QAction::triggered, this, &Klipper::speakClipboardContents); + + m_stopSpeakingAction = m_collection->addAction(QStringLiteral("stop-speech")); + m_stopSpeakingAction->setText(i18n("Stop speaking")); + KGlobalAccel::setGlobalShortcut(m_stopSpeakingAction, QKeySequence()); + connect(m_stopSpeakingAction, &QAction::triggered, this, &Klipper::stopSpeaking); + + m_pauseResumeSpeakingAction = m_collection->addAction(QStringLiteral("pause-resume-speech")); + m_pauseResumeSpeakingAction->setText(i18n("Pause/Resume speaking")); + KGlobalAccel::setGlobalShortcut(m_pauseResumeSpeakingAction, QKeySequence()); + connect(m_pauseResumeSpeakingAction, &QAction::triggered, this, &Klipper::pauseResumeSpeaking); +#endif + // Cycle through history m_cycleNextAction = m_collection->addAction(QStringLiteral("cycleNextAction")); m_cycleNextAction->setText(i18n("Next History Item")); @@ -981,6 +1012,30 @@ } #endif //HAVE_PRISON +#ifdef HAVE_SPEECH +void Klipper::speakTextSelection() +{ + // Get text selection and send to tts engine + m_tts->say(m_clip->text(QClipboard::Selection)); +} + +void Klipper::speakClipboardContents() +{ + // Get clipboard contents, sanitize and send to tts engine + m_tts->say(m_clip->text()); +} + +void Klipper::stopSpeaking() +{ + m_tts->stopSpeaking(); +} + +void Klipper::pauseResumeSpeaking() +{ + m_tts->pauseResumeSpeaking(); +} +#endif + void Klipper::slotAskClearHistory() { int clearHist = KMessageBox::questionYesNo(nullptr, diff --git a/klipper/klipper.kcfg b/klipper/klipper.kcfg --- a/klipper/klipper.kcfg +++ b/klipper/klipper.kcfg @@ -69,6 +69,9 @@ -1 + + speechd + diff --git a/klipper/tts.h b/klipper/tts.h new file mode 100644 --- /dev/null +++ b/klipper/tts.h @@ -0,0 +1,43 @@ +/*************************************************************************** + * Copyright (C) 2008 by Pino Toscano * + * Copyright (C) 2019 by Jeremy Whiting * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ***************************************************************************/ + +#ifndef _TTS_H_ +#define _TTS_H_ + +#include +#include + +class KlipperTTS : public QObject +{ + Q_OBJECT + public: + explicit KlipperTTS( QObject *parent = nullptr ); + ~KlipperTTS(); + + void say( const QString &text ); + void stopSpeaking(); + void pauseResumeSpeaking(); + + public slots: + void slotSpeechStateChanged(QTextToSpeech::State state); + void slotConfigChanged(); + + signals: + void isSpeaking( bool speaking ); + void canPauseOrResume( bool speakingOrPaused ); + + private: + // private storage + class Private; + Private *d; +}; + +#endif + diff --git a/klipper/tts.cpp b/klipper/tts.cpp new file mode 100644 --- /dev/null +++ b/klipper/tts.cpp @@ -0,0 +1,115 @@ +/*************************************************************************** + * Copyright (C) 2008 by Pino Toscano * + * Copyright (C) 2019 by Jeremy Whiting * + * * + * This program is free software; you can redistribute it and/or modify * + * it under the terms of the GNU General Public License as published by * + * the Free Software Foundation; either version 2 of the License, or * + * (at your option) any later version. * + ***************************************************************************/ + +#include "tts.h" + +#include +#include + +#include + +#include "klippersettings.h" + +/* Private storage. */ +class KlipperTTS::Private +{ +public: + Private( KlipperTTS *qq ) + : q( qq ), speech( new QTextToSpeech( KlipperSettings::ttsEngine() ) ) + { + } + + ~Private() + { + delete speech; + speech = nullptr; + } + + KlipperTTS *q; + QTextToSpeech *speech; + // Which speech engine was used when above object was created. + // When the setting changes, we need to stop speaking and recreate. + QString speechEngine; +}; + +KlipperTTS::KlipperTTS( QObject *parent ) + : QObject( parent ), d( new Private( this ) ) +{ + // Initialize speechEngine so we can reinitialize if it changes. + d->speechEngine = KlipperSettings::ttsEngine(); + connect( d->speech, &QTextToSpeech::stateChanged, this, &KlipperTTS::slotSpeechStateChanged); + connect( KlipperSettings::self(), &KConfigSkeleton::configChanged, + this, &KlipperTTS::slotConfigChanged); +} + +KlipperTTS::~KlipperTTS() +{ + delete d; +} + +void KlipperTTS::say( const QString &text ) +{ + if ( text.isEmpty() ) + return; + + d->speech->say( text ); +} + +void KlipperTTS::stopSpeaking() +{ + if ( !d->speech ) + return; + + d->speech->stop(); +} + +void KlipperTTS::pauseResumeSpeaking() +{ + if ( !d->speech ) + return; + + if ( d->speech->state() == QTextToSpeech::Speaking ) + d->speech->pause(); + else + d->speech->resume(); +} + +void KlipperTTS::slotSpeechStateChanged(QTextToSpeech::State state) +{ + if (state == QTextToSpeech::Speaking) + { + emit isSpeaking(true); + emit canPauseOrResume(true); + } + else + { + emit isSpeaking(false); + if (state == QTextToSpeech::Paused) + emit canPauseOrResume(true); + else + emit canPauseOrResume(false); + } +} + +void KlipperTTS::slotConfigChanged() +{ + const QString engine = KlipperSettings::ttsEngine(); + if (engine != d->speechEngine) + { + d->speech->stop(); + delete d->speech; + d->speech = new QTextToSpeech(engine); + connect( d->speech, &QTextToSpeech::stateChanged, this, &KlipperTTS::slotSpeechStateChanged); + d->speechEngine = engine; + } +} + +#include "moc_tts.cpp" +