diff --git a/CMakeLists.txt b/CMakeLists.txt index 04cdbd70e09..9c3cfc62be8 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -134,7 +134,6 @@ find_package(Qt6 Sql Svg Test - TextToSpeech Widgets Xml OPTIONAL_COMPONENTS diff --git a/src/AnalyzeView/CMakeLists.txt b/src/AnalyzeView/CMakeLists.txt index 6946ce1e738..48be200dd36 100644 --- a/src/AnalyzeView/CMakeLists.txt +++ b/src/AnalyzeView/CMakeLists.txt @@ -35,7 +35,6 @@ target_link_libraries(AnalyzeView PUBLIC Qt6::Charts Qt6::Location - Qt6::TextToSpeech Qt6::Widgets ) diff --git a/src/Audio/AudioOutput.cc b/src/Audio/AudioOutput.cc index 7315c879e89..21555810b00 100644 --- a/src/Audio/AudioOutput.cc +++ b/src/Audio/AudioOutput.cc @@ -7,191 +7,186 @@ * ****************************************************************************/ -#include -#include - #include "AudioOutput.h" -#include "QGCApplication.h" -#include "SettingsManager.h" -AudioOutput::AudioOutput(QGCApplication* app, QGCToolbox* toolbox) - : QGCTool (app, toolbox) - , _tts (nullptr) -{ - if (qgcApp()->runningUnitTests()) { - // Cloud based unit tests don't have speech capabilty. If you try to crank up - // speech engine it will pop a qWarning which prevents usage of QT_FATAL_WARNINGS - return; - } +#include + +#define MAX_TEXT_QUEUE_SIZE 20U + +Q_LOGGING_CATEGORY( AudioOutputLog, "qgc.audio.audiooutput" ); +// qt.speech.tts.flite +// qt.speech.tts.android + +const QHash AudioOutput::s_textHash = { + { "ERR", "error" }, + { "POSCTL", "Position Control" }, + { "ALTCTL", "Altitude Control" }, + { "AUTO_RTL", "auto return to launch" }, + { "RTL", "return To launch" }, + { "ACCEL", "accelerometer" }, + { "RC_MAP_MODE_SW", "RC mode switch" }, + { "REJ", "rejected" }, + { "WP", "waypoint" }, + { "CMD", "command" }, + { "COMPID", "component eye dee" }, + { "params", "parameters" }, + { "id", "I.D." }, + { "ADSB", "A.D.S.B." }, + { "EKF", "E.K.F." }, + { "PREARM", "pre arm" }, + { "PITOT", "pee toe" }, +}; - _tts = new QTextToSpeech(this); +Q_APPLICATION_STATIC( AudioOutput, s_audioOutput ); - //-- Force TTS engine to English as all incoming messages from the autopilot - // are in English and not localized. -#ifdef Q_OS_LINUX - _tts->setLocale(QLocale("en_US")); -#endif - connect(_tts, &QTextToSpeech::stateChanged, this, &AudioOutput::_stateChanged); +AudioOutput* AudioOutput::instance() +{ + return s_audioOutput(); } -void AudioOutput::say(const QString& inText) +AudioOutput::AudioOutput( QObject* parent ) + : QTextToSpeech( QStringLiteral("none"), parent ) { - if (!_tts) { - qDebug() << "say" << inText; - return; - } + #ifdef QT_DEBUG + ( void ) connect( this, &QTextToSpeech::stateChanged, []( QTextToSpeech::State state ) { + qCInfo( AudioOutputLog ) << Q_FUNC_INFO << "State:" << state; + }); + ( void ) connect( this, &QTextToSpeech::errorOccurred, []( QTextToSpeech::ErrorReason reason, const QString &errorString ) { + ( void ) reason; + qCInfo( AudioOutputLog ) << Q_FUNC_INFO << "Error:" << errorString; + }); + #endif - bool muted = qgcApp()->toolbox()->settingsManager()->appSettings()->audioMuted()->rawValue().toBool(); - muted |= qgcApp()->runningUnitTests(); - if (!muted && !qgcApp()->runningUnitTests()) { - QString text = fixTextMessageForAudio(inText); - if(_tts->state() == QTextToSpeech::Speaking) { - if(!_texts.contains(text)) { - //-- Some arbitrary limit - if(_texts.size() > 20) { - _texts.removeFirst(); - } - _texts.append(text); + if ( !QTextToSpeech::availableEngines().isEmpty() ) { + if ( setEngine( QString() ) ) { // Autoselect engine by priority + if ( availableLocales().contains( QLocale( "en_US" ) ) ) { + setLocale( QLocale( "en_US" ) ); } - } else { - _tts->say(text); + + ( void ) connect( this, &QTextToSpeech::volumeChanged, [ this ]( double volume ) { + ( void ) volume; + const bool muted = isMuted(); + if ( muted != m_lastMuted ) { + emit mutedChanged( muted ); + m_lastMuted = muted; + } + }); } } } -void AudioOutput::_stateChanged(QTextToSpeech::State state) +bool AudioOutput::isMuted() const { - if(state == QTextToSpeech::Ready) { - if(_texts.size()) { - QString text = _texts.first(); - _texts.removeFirst(); - _tts->say(text); - } - } + return qFuzzyIsNull( volume() ); } -bool AudioOutput::getMillisecondString(const QString& string, QString& match, int& number) { - static QRegularExpression re("([0-9]+ms)"); - QRegularExpressionMatchIterator i = re.globalMatch(string); - while (i.hasNext()) { - QRegularExpressionMatch qmatch = i.next(); - if (qmatch.hasMatch()) { - match = qmatch.captured(0); - number = qmatch.captured(0).replace("ms", "").toInt(); - return true; - } +void AudioOutput::setMuted( bool enable ) +{ + if ( enable != isMuted() ) { + ( void ) QMetaObject::invokeMethod( this, "setVolume", Qt::AutoConnection, enable ? 0. : 100. ); } - return false; } -QString AudioOutput::fixTextMessageForAudio(const QString& string) { - QString match; - QString newNumber; - QString result = string; - - //-- Look for codified terms - if(result.contains("ERR ", Qt::CaseInsensitive)) { - result.replace("ERR ", "error ", Qt::CaseInsensitive); - } - if(result.contains("ERR:", Qt::CaseInsensitive)) { - result.replace("ERR:", "error.", Qt::CaseInsensitive); - } - if(result.contains("POSCTL", Qt::CaseInsensitive)) { - result.replace("POSCTL", "Position Control", Qt::CaseInsensitive); - } - if(result.contains("ALTCTL", Qt::CaseInsensitive)) { - result.replace("ALTCTL", "Altitude Control", Qt::CaseInsensitive); - } - if(result.contains("AUTO_RTL", Qt::CaseInsensitive)) { - result.replace("AUTO_RTL", "auto Return To Launch", Qt::CaseInsensitive); - } else if(result.contains("RTL", Qt::CaseInsensitive)) { - result.replace("RTL", "Return To Launch", Qt::CaseInsensitive); - } - if(result.contains("ACCEL ", Qt::CaseInsensitive)) { - result.replace("ACCEL ", "accelerometer ", Qt::CaseInsensitive); - } - if(result.contains("RC_MAP_MODE_SW", Qt::CaseInsensitive)) { - result.replace("RC_MAP_MODE_SW", "RC mode switch", Qt::CaseInsensitive); - } - if(result.contains("REJ.", Qt::CaseInsensitive)) { - result.replace("REJ.", "Rejected", Qt::CaseInsensitive); - } - if(result.contains("WP", Qt::CaseInsensitive)) { - result.replace("WP", "way point", Qt::CaseInsensitive); - } - if(result.contains("CMD", Qt::CaseInsensitive)) { - result.replace("CMD", "command", Qt::CaseInsensitive); - } - if(result.contains("COMPID", Qt::CaseInsensitive)) { - result.replace("COMPID", "component eye dee", Qt::CaseInsensitive); - } - if(result.contains(" params ", Qt::CaseInsensitive)) { - result.replace(" params ", " parameters ", Qt::CaseInsensitive); +void AudioOutput::say( const QString& text, AudioOutput::TextMods textMods ) +{ + if ( !engineCapabilities().testFlag( QTextToSpeech::Capability::Speak ) ) { + qCWarning( AudioOutputLog ) << Q_FUNC_INFO << "Speech Not Supported:" << text; + return; } - if(result.contains(" id ", Qt::CaseInsensitive)) { - result.replace(" id ", " eye dee ", Qt::CaseInsensitive); + + if ( m_textQueueSize > MAX_TEXT_QUEUE_SIZE ) { + ( void ) QMetaObject::invokeMethod( this, "stop", Qt::AutoConnection, QTextToSpeech::BoundaryHint::Default ); } - if(result.contains(" ADSB ", Qt::CaseInsensitive)) { - result.replace(" ADSB ", " Hey Dee Ess Bee ", Qt::CaseInsensitive); + + QString outText = AudioOutput::fixTextMessageForAudio( text ); + + if ( textMods.testFlag( AudioOutput::TextMod::Translate ) ) { + outText = QCoreApplication::translate("AudioOutput", outText.toStdString().c_str() ); } - if(result.contains(" EKF ", Qt::CaseInsensitive)) { - result.replace(" EKF ", " Eee Kay Eff ", Qt::CaseInsensitive); + + qsizetype index = -1; + ( void ) QMetaObject::invokeMethod( this, "enqueue", Qt::AutoConnection, qReturnArg( m_textQueueSize ), outText ); + if ( index != -1 ) { + m_textQueueSize = index; } - if(result.contains("PREARM", Qt::CaseInsensitive)) { - result.replace("PREARM", "pre arm", Qt::CaseInsensitive); +} + +bool AudioOutput::getMillisecondString( const QString& string, QString& match, int& number ) +{ + static const QRegularExpression regexp( "([0-9]+ms)" ); + + bool result = false; + + for ( const QRegularExpressionMatch &tempMatch : regexp.globalMatch( string ) ) { + if ( tempMatch.hasMatch() ) { + match = tempMatch.captured( 0 ); + number = tempMatch.captured( 0 ).replace( "ms", "" ).toInt(); + result = true; + break; + } } - if(result.contains("PITOT", Qt::CaseInsensitive)) { - result.replace("PITOT", "pee toe", Qt::CaseInsensitive); + + return result; +} + +QString AudioOutput::fixTextMessageForAudio( const QString& string ) +{ + QString result = string; + + for ( const QString& word: string.split( ' ', Qt::SkipEmptyParts ) ) { + if ( s_textHash.contains( word.toUpper() ) ) { + result.replace( word, s_textHash.value( word.toUpper() ) ); + } } // Convert negative numbers - QRegularExpression re(QStringLiteral("(-)[0-9]*\\.?[0-9]")); - QRegularExpressionMatch reMatch = re.match(result); - while (reMatch.hasMatch()) { - if (!reMatch.captured(1).isNull()) { - // There is a negative prefix - result.replace(reMatch.capturedStart(1), reMatch.capturedEnd(1) - reMatch.capturedStart(1), tr(" negative ")); + static const QRegularExpression negNumRegex( QStringLiteral( "(-)[0-9]*\\.?[0-9]" ) ); + QRegularExpressionMatch negNumRegexMatch = negNumRegex.match( result ); + while ( negNumRegexMatch.hasMatch() ) { + if ( !negNumRegexMatch.captured( 1 ).isNull() ) { + result.replace( negNumRegexMatch.capturedStart( 1 ), negNumRegexMatch.capturedEnd( 1 ) - negNumRegexMatch.capturedStart( 1 ), tr(" negative ")); } - reMatch = re.match(result); + negNumRegexMatch = negNumRegex.match( result ); } // Convert real number with decimal point - re.setPattern(QStringLiteral("([0-9]+)(\\.)([0-9]+)")); - reMatch = re.match(result); - while (reMatch.hasMatch()) { - if (!reMatch.captured(2).isNull()) { - // There is a decimal point - result.replace(reMatch.capturedStart(2), reMatch.capturedEnd(2) - reMatch.capturedStart(2), tr(" point ")); + static const QRegularExpression realNumRegex( QStringLiteral( "([0-9]+)(\\.)([0-9]+)" ) ); + QRegularExpressionMatch realNumRegexMatch = realNumRegex.match( result ); + while ( realNumRegexMatch.hasMatch() ) { + if ( !realNumRegexMatch.captured( 2 ).isNull() ) { + result.replace(realNumRegexMatch.capturedStart( 2 ), realNumRegexMatch.capturedEnd( 2 ) - realNumRegexMatch.capturedStart( 2 ), tr( " point " )); } - reMatch = re.match(result); + realNumRegexMatch = realNumRegex.match( result ); } // Convert meter postfix after real number - re.setPattern(QStringLiteral("[0-9]*\\.?[0-9]\\s?(m)([^A-Za-z]|$)")); - reMatch = re.match(result); - while (reMatch.hasMatch()) { - if (!reMatch.captured(1).isNull()) { - // There is a meter postfix - result.replace(reMatch.capturedStart(1), reMatch.capturedEnd(1) - reMatch.capturedStart(1), tr(" meters")); + static const QRegularExpression realNumMeterRegex( QStringLiteral( "[0-9]*\\.?[0-9]\\s?(m)([^A-Za-z]|$)" ) ); + QRegularExpressionMatch realNumMeterRegexMatch = realNumMeterRegex.match( result ); + while ( realNumMeterRegexMatch.hasMatch() ) { + if ( !realNumMeterRegexMatch.captured( 1 ).isNull( ) ) { + result.replace( realNumMeterRegexMatch.capturedStart( 1 ), realNumMeterRegexMatch.capturedEnd( 1 ) - realNumMeterRegexMatch.capturedStart( 1 ), tr(" meters")); } - reMatch = re.match(result); + realNumMeterRegexMatch = realNumMeterRegex.match( result ); } + QString match; int number; - if(getMillisecondString(string, match, number) && number > 1000) { - if(number < 60000) { - int seconds = number / 1000; - newNumber = QString("%1 second%2").arg(seconds).arg(seconds > 1 ? "s" : ""); + if ( getMillisecondString( string, match, number ) && ( number > 1000 ) ) { + QString newNumber; + if ( number < 60000 ) { + const int seconds = number / 1000; + newNumber = QString( "%1 second%2" ).arg( seconds ).arg( seconds > 1 ? "s" : "" ); } else { - int minutes = number / 60000; - int seconds = (number - (minutes * 60000)) / 1000; - if (!seconds) { - newNumber = QString("%1 minute%2").arg(minutes).arg(minutes > 1 ? "s" : ""); + const int minutes = number / 60000; + const int seconds = ( number - ( minutes * 60000 ) ) / 1000; + if ( !seconds ) { + newNumber = QString( "%1 minute%2" ).arg( minutes ).arg( minutes > 1 ? "s" : "" ); } else { - newNumber = QString("%1 minute%2 and %3 second%4").arg(minutes).arg(minutes > 1 ? "s" : "").arg(seconds).arg(seconds > 1 ? "s" : ""); + newNumber = QString( "%1 minute%2 and %3 second%4" ).arg( minutes ).arg( minutes > 1 ? "s" : "" ).arg( seconds ).arg( seconds > 1 ? "s" : "" ); } } - result.replace(match, newNumber); + result.replace( match, newNumber ); } + return result; } diff --git a/src/Audio/AudioOutput.h b/src/Audio/AudioOutput.h index 404b1ca986f..3c967895acc 100644 --- a/src/Audio/AudioOutput.h +++ b/src/Audio/AudioOutput.h @@ -9,32 +9,45 @@ #pragma once -#include -#include +#include +#include +#include -#include "QGCToolbox.h" +Q_DECLARE_LOGGING_CATEGORY( AudioOutputLog ) -class QGCApplication; - -/// Text to Speech Interface -class AudioOutput : public QGCTool +class AudioOutput : public QTextToSpeech { Q_OBJECT + QML_ELEMENT + QML_UNCREATABLE( "" ) + + Q_PROPERTY( bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged ) + public: - AudioOutput(QGCApplication* app, QGCToolbox* toolbox); + enum class TextMod { + None = 0, + Translate = 1 << 0, + }; + Q_DECLARE_FLAGS( TextMods, TextMod ) + Q_FLAG( TextMod ) - static bool getMillisecondString (const QString& string, QString& match, int& number); - static QString fixTextMessageForAudio (const QString& string); + explicit AudioOutput( QObject* parent = nullptr ); -public slots: - /// Convert string to speech output and say it - void say (const QString& text); + bool isMuted() const; + void setMuted( bool enable ); -private slots: - void _stateChanged (QTextToSpeech::State state); + void say( const QString& text, AudioOutput::TextMods textMods = TextMod::None ); -protected: - QTextToSpeech* _tts; - QStringList _texts; -}; + static AudioOutput* instance(); + static bool getMillisecondString( const QString& string, QString& match, int& number ); + static QString fixTextMessageForAudio( const QString& string ); + +signals: + void mutedChanged( bool muted ); +private: + qsizetype m_textQueueSize = 0; + bool m_lastMuted = false; + static const QHash s_textHash; +}; +Q_DECLARE_OPERATORS_FOR_FLAGS( AudioOutput::TextMods ) diff --git a/src/Audio/CMakeLists.txt b/src/Audio/CMakeLists.txt index 2e8cc47f3f3..50acd0f98d5 100644 --- a/src/Audio/CMakeLists.txt +++ b/src/Audio/CMakeLists.txt @@ -1,4 +1,4 @@ -find_package(Qt6 REQUIRED COMPONENTS Core TextToSpeech Widgets) +find_package(Qt6 REQUIRED COMPONENTS Core Qml TextToSpeech) qt_add_library(Audio STATIC AudioOutput.cc @@ -7,10 +7,10 @@ qt_add_library(Audio STATIC target_link_libraries(Audio PRIVATE - Qt6::Widgets - qgc + Utilities PUBLIC Qt6::Core + Qt6::QmlIntegration Qt6::TextToSpeech ) diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt index ba68f936b3a..a24862b6d58 100644 --- a/src/CMakeLists.txt +++ b/src/CMakeLists.txt @@ -46,6 +46,7 @@ add_subdirectory(Viewer3D) ####################################################### target_link_libraries(qgc PRIVATE + Audio PUBLIC Qt6::QuickControls2 Qt6::QuickWidgets @@ -56,7 +57,6 @@ target_link_libraries(qgc AirLink AnalyzeView api - Audio AutoPilotPlugins Camera comm diff --git a/src/QGCApplication.cc b/src/QGCApplication.cc index 968a6d64691..9612637e3a2 100644 --- a/src/QGCApplication.cc +++ b/src/QGCApplication.cc @@ -105,7 +105,7 @@ #include "RemoteIDManager.h" #include "CustomAction.h" #include "CustomActionManager.h" - +#include "AudioOutput.h" #include "CityMapGeometry.h" #include "Viewer3DQmlBackend.h" #include "Viewer3DQmlVariableTypes.h" @@ -522,6 +522,11 @@ bool QGCApplication::_initForNormalAppBoot() { QSettings settings; + ( void ) connect( toolbox()->settingsManager()->appSettings()->audioMuted(), &Fact::valueChanged, AudioOutput::instance(), []( QVariant value ) + { + AudioOutput::instance()->setMuted( value.toBool() ); + }); + _qmlAppEngine = toolbox()->corePlugin()->createQmlApplicationEngine(this); toolbox()->corePlugin()->createRootWindow(_qmlAppEngine); diff --git a/src/QGCApplication.h b/src/QGCApplication.h index b15e63706f3..1c70107680a 100644 --- a/src/QGCApplication.h +++ b/src/QGCApplication.h @@ -28,7 +28,6 @@ #include "FirmwarePluginManager.h" #include "MultiVehicleManager.h" #include "JoystickManager.h" -#include "AudioOutput.h" #include "UASMessageHandler.h" #include "FactSystem.h" diff --git a/src/QGCToolbox.cc b/src/QGCToolbox.cc index 0679175077a..b0be7830bfa 100644 --- a/src/QGCToolbox.cc +++ b/src/QGCToolbox.cc @@ -10,7 +10,6 @@ #include "FactSystem.h" #include "FirmwarePluginManager.h" -#include "AudioOutput.h" #ifndef __mobile__ #include "GPSManager.h" #endif @@ -64,7 +63,6 @@ QGCToolbox::QGCToolbox(QGCApplication* app) //-- Scan and load plugins _scanAndLoadPlugins(app); - _audioOutput = new AudioOutput (app, this); _factSystem = new FactSystem (app, this); _firmwarePluginManager = new FirmwarePluginManager (app, this); #ifndef __mobile__ @@ -100,7 +98,6 @@ void QGCToolbox::setChildToolboxes(void) _settingsManager->setToolbox(this); _corePlugin->setToolbox(this); - _audioOutput->setToolbox(this); _factSystem->setToolbox(this); _firmwarePluginManager->setToolbox(this); #ifndef __mobile__ diff --git a/src/QGCToolbox.h b/src/QGCToolbox.h index 5435a0ce757..466a87a68fe 100644 --- a/src/QGCToolbox.h +++ b/src/QGCToolbox.h @@ -15,7 +15,6 @@ class FactSystem; class FirmwarePluginManager; -class AudioOutput; class GPSManager; class JoystickManager; class FollowMe; @@ -48,7 +47,6 @@ class QGCToolbox : public QObject { QGCToolbox(QGCApplication* app); FirmwarePluginManager* firmwarePluginManager () { return _firmwarePluginManager; } - AudioOutput* audioOutput () { return _audioOutput; } JoystickManager* joystickManager () { return _joystickManager; } LinkManager* linkManager () { return _linkManager; } MAVLinkProtocol* mavlinkProtocol () { return _mavlinkProtocol; } @@ -79,7 +77,6 @@ class QGCToolbox : public QObject { void _scanAndLoadPlugins(QGCApplication *app); - AudioOutput* _audioOutput = nullptr; FactSystem* _factSystem = nullptr; FirmwarePluginManager* _firmwarePluginManager = nullptr; #ifndef __mobile__ diff --git a/src/QmlControls/CMakeLists.txt b/src/QmlControls/CMakeLists.txt index c700ba93ba5..911705f4b58 100644 --- a/src/QmlControls/CMakeLists.txt +++ b/src/QmlControls/CMakeLists.txt @@ -148,7 +148,6 @@ target_link_libraries(QmlControls PUBLIC Qt6::Concurrent Qt6::Location - Qt6::TextToSpeech Qt6::Widgets ) diff --git a/src/Vehicle/CMakeLists.txt b/src/Vehicle/CMakeLists.txt index b2306c1c291..e8394d60d4d 100644 --- a/src/Vehicle/CMakeLists.txt +++ b/src/Vehicle/CMakeLists.txt @@ -97,11 +97,12 @@ qt_add_library(Vehicle STATIC target_link_libraries(Vehicle PRIVATE + Audio compression PUBLIC qgc libevents - Actuators + Actuators ) target_include_directories(Vehicle PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) diff --git a/src/Vehicle/Vehicle.cc b/src/Vehicle/Vehicle.cc index 812c9029302..a626f87e75e 100644 --- a/src/Vehicle/Vehicle.cc +++ b/src/Vehicle/Vehicle.cc @@ -50,6 +50,7 @@ #include "MockLink.h" #endif #include "RemoteIDManager.h" +#include "AudioOutput.h" QGC_LOGGING_CATEGORY(VehicleLog, "VehicleLog") @@ -929,7 +930,7 @@ void Vehicle::_chunkedStatusTextCompleted(uint8_t compId) if (readAloud) { if (!skipSpoken) { - qgcApp()->toolbox()->audioOutput()->say(messageText); + _say(messageText); } } emit textMessageReceived(id(), compId, severity, messageText.toHtmlEscaped(), ""); @@ -2510,7 +2511,7 @@ void Vehicle::virtualTabletJoystickValue(double roll, double pitch, double yaw, void Vehicle::_say(const QString& text) { - _toolbox->audioOutput()->say(text.toLower()); + AudioOutput::instance()->say(text.toLower()); } bool Vehicle::airship() const @@ -4365,7 +4366,7 @@ void Vehicle::_handleFenceStatus(const mavlink_message_t& message) break; } - qgcApp()->toolbox()->audioOutput()->say(breachTypeStr + " " + tr("fence breached")); + _say(breachTypeStr + " " + tr("fence breached")); } } else { lastUpdate = now; diff --git a/test/Audio/CMakeLists.txt b/test/Audio/CMakeLists.txt index 3f9dd25967b..eb50d96bcde 100644 --- a/test/Audio/CMakeLists.txt +++ b/test/Audio/CMakeLists.txt @@ -5,8 +5,9 @@ qt_add_library(AudioTest ) target_link_libraries(AudioTest + PRIVATE + Audio PUBLIC - qgc qgcunittest )