QGroundControl
Ground Control Station for MAVLink Drones
Loading...
Searching...
No Matches
AudioOutput.cc
Go to the documentation of this file.
1#include "AudioOutput.h"
2#include "Fact.h"
3#include "AppMessages.h"
5
6#include <QtCore/QRegularExpression>
7#include <QtCore/QApplicationStatic>
8#include <QtTextToSpeech/QTextToSpeech>
9
10#include <algorithm>
11
12QGC_LOGGING_CATEGORY(AudioOutputLog, "Utilities.AudioOutput");
13// qt.speech.tts.flite
14// qt.speech.tts.android
15
16const QHash<QString, QString> AudioOutput::_textHash = {
17 { "ERR", "error" },
18 { "POSCTL", "Position Control" },
19 { "ALTCTL", "Altitude Control" },
20 { "AUTO_RTL", "auto return to launch" },
21 { "RTL", "return To launch" },
22 { "ACCEL", "accelerometer" },
23 { "RC_MAP_MODE_SW", "RC mode switch" },
24 { "REJ", "rejected" },
25 { "WP", "waypoint" },
26 { "CMD", "command" },
27 { "COMPID", "component eye dee" },
28 { "PARAMS", "parameters" },
29 { "ID", "I.D." },
30 { "ADSB", "A.D.S.B." },
31 { "EKF", "E.K.F." },
32 { "PREARM", "pre arm" },
33 { "PITOT", "pee toe" },
34 { "SERVOX_FUNCTION","Servo X Function" },
35};
36
38
40 : QObject(parent)
41 , _engine(new QTextToSpeech(QStringLiteral("none"), this))
42{
43 // qCDebug(AudioOutputLog) << this;
44}
45
47{
48 // qCDebug(AudioOutputLog) << this;
49}
50
52{
53 return _audioOutput();
54}
55
56void AudioOutput::init(Fact* volumeFact, Fact* mutedFact)
57{
58 Q_CHECK_PTR(volumeFact);
59 Q_CHECK_PTR(mutedFact);
60
61 if (_initialized) {
62 return;
63 }
64
65 if (QTextToSpeech::availableEngines().isEmpty()) {
66 qCWarning(AudioOutputLog) << "No available QTextToSpeech engines found.";
67 return;
68 }
69
70 // Autoselect engine by priority
71 if (!_engine->setEngine(QString())) {
72 qCWarning(AudioOutputLog) << "Failed to set the TTS engine.";
73 return;
74 }
75
76 (void) connect(_engine, &QTextToSpeech::engineChanged, this, [this](const QString &engine) {
77 qCDebug(AudioOutputLog) << "TTS Engine set to:" << engine;
78 const QLocale defaultLocale = QLocale("en_US");
79 if (_engine->availableLocales().contains(defaultLocale)) {
80 _engine->setLocale(defaultLocale);
81 }
82 });
83
84 (void) connect(_engine, &QTextToSpeech::aboutToSynthesize, this, [this](qsizetype id) {
85 qCDebug(AudioOutputLog) << "TTS About To Synthesize ID:" << id;
86 _textQueueSize--;
87 qCDebug(AudioOutputLog) << "Queue Size:" << _textQueueSize;
88 });
89
90 _volumeFact = volumeFact;
91 _mutedFact = mutedFact;
92
93 (void) connect(_volumeFact, &Fact::valueChanged, this, [this]() {
94 _setVolume();
95 });
96
97 (void) connect(_mutedFact, &Fact::valueChanged, this, [this]() {
98 _setVolume();
99 });
100
101 if (AudioOutputLog().isDebugEnabled()) {
102 (void) connect(_engine, &QTextToSpeech::stateChanged, this, [](QTextToSpeech::State state) {
103 qCDebug(AudioOutputLog) << "TTS State changed to:" << state;
104 });
105 (void) connect(_engine, &QTextToSpeech::errorOccurred, this, [](QTextToSpeech::ErrorReason reason, const QString &errorString) {
106 qCDebug(AudioOutputLog) << "TTS Error occurred. Reason:" << reason << ", Message:" << errorString;
107 });
108 (void) connect(_engine, &QTextToSpeech::localeChanged, this, [](const QLocale &locale) {
109 qCDebug(AudioOutputLog) << "TTS Locale change to:" << locale;
110 });
111 (void) connect(_engine, &QTextToSpeech::volumeChanged, this, [](double volume) {
112 qCDebug(AudioOutputLog) << "TTS Volume changed to:" << volume;
113 });
114 (void) connect(_engine, &QTextToSpeech::sayingWord, this, [](const QString &word, qsizetype id, qsizetype start, qsizetype length) {
115 qCDebug(AudioOutputLog) << "TTS Saying:" << word << "ID:" << id << "Start:" << start << "Length:" << length;
116 });
117 }
118
119 _initialized = true;
120 _setVolume();
121
122 qCDebug(AudioOutputLog) << "AudioOutput initialized with volume:" << _volumeSetting() << "%";
123}
124
125double AudioOutput::_volumeSetting() const
126{
127 return std::clamp(_volumeFact->rawValue().toDouble(), 0.0, 100.0);
128}
129
130bool AudioOutput::_mutedSetting() const
131{
132 return _mutedFact->rawValue().toBool();
133}
134
135void AudioOutput::_setVolume()
136{
137 const bool muted = _mutedSetting();
138 const double volume = muted ? 0.0 : _volumeSetting();
139
140 // qFuzzyCompare fails near zero; adding 1.0 shifts values into a safe range
141 if (qFuzzyCompare(1.0 + volume, 1.0 + _lastVolume)) {
142 return;
143 }
144 _lastVolume = volume;
145
146 if (volume == 0.0) {
147 // Prevent any queued text from being spoken once muted
148 (void) QMetaObject::invokeMethod(_engine, "stop", Qt::AutoConnection, QTextToSpeech::BoundaryHint::Default);
149 _textQueueSize = 0;
150 }
151
152 // Must normalize volume to 0.0 - 1.0 for QTextToSpeech
153 const double normalizedVolume = volume / 100.0;
154 (void) QMetaObject::invokeMethod(_engine, "setVolume", Qt::AutoConnection, normalizedVolume);
155 qCDebug(AudioOutputLog) << "AudioOutput volume set to:" << volume << "%";
156}
157
158void AudioOutput::say(const QString &text, TextMods textMods)
159{
160 if (!_initialized) {
161 if (!QGC::runningUnitTests()) {
162 qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using say().";
163 }
164 return;
165 }
166
167 if (_volumeSetting() <= 0.0 || _mutedSetting()) {
168 return;
169 }
170
171 if (!_engine->engineCapabilities().testFlag(QTextToSpeech::Capability::Speak)) {
172 qCWarning(AudioOutputLog) << "Speech Not Supported:" << text;
173 return;
174 }
175
176 if (_textQueueSize >= kMaxTextQueueSize) {
177 (void) QMetaObject::invokeMethod(_engine, "stop", Qt::AutoConnection, QTextToSpeech::BoundaryHint::Default);
178 _textQueueSize = 0;
179 qCWarning(AudioOutputLog) << "Text queue exceeded maximum size. Stopped current speech.";
180 }
181
182 QString outText = _fixTextMessageForAudio(text);
183
184 if (textMods.testFlag(TextMod::Translate)) {
185 outText = tr("%1").arg(outText);
186 }
187
188 qsizetype index;
189 if (QMetaObject::invokeMethod(_engine, "enqueue", Qt::AutoConnection, qReturnArg(index), outText)) {
190 if (index != -1) {
191 _textQueueSize++;
192 qCDebug(AudioOutputLog) << "Enqueued text with index:" << index << ", Queue Size:" << _textQueueSize;
193 }
194 } else {
195 qCWarning(AudioOutputLog) << "Failed to invoke Enqueue method.";
196 }
197}
198
200{
201 if (!_initialized) {
202 qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using testAudioOutput().";
203 return;
204 }
205
206 (void) QMetaObject::invokeMethod(_engine, "stop", Qt::AutoConnection, QTextToSpeech::BoundaryHint::Default);
207 _textQueueSize = 0;
208
209 const QString testText = tr("Audio test. Volume is %1 percent").arg(_volumeSetting(), 0, 'f', 1);
210 say(testText);
211}
212
213QString AudioOutput::_fixTextMessageForAudio(const QString &string)
214{
215 QString result = string;
216 result = _replaceAbbreviations(result);
217 result = _replaceNegativeSigns(result);
218 result = _replaceDecimalPoints(result);
219 result = _replaceMeters(result);
220 result = _convertMilliseconds(result);
221 return result;
222}
223
224QString AudioOutput::_replaceAbbreviations(const QString &input)
225{
226 QString output = input;
227
228 const QStringList wordList = input.split(' ', Qt::SkipEmptyParts);
229 for (const QString &word : wordList) {
230 const QString upperWord = word.toUpper();
231 if (_textHash.contains(upperWord)) {
232 (void) output.replace(word, _textHash.value(upperWord));
233 }
234 }
235
236 return output;
237}
238
239QString AudioOutput::_replaceNegativeSigns(const QString &input)
240{
241 static const QRegularExpression negNumRegex(QStringLiteral("-\\s*(?=\\d)"));
242 Q_ASSERT(negNumRegex.isValid());
243
244 QString output = input;
245 (void) output.replace(negNumRegex, "negative ");
246 return output;
247}
248
249QString AudioOutput::_replaceDecimalPoints(const QString &input)
250{
251 static const QRegularExpression realNumRegex(QStringLiteral("([0-9]+)(\\.)([0-9]+)"));
252 Q_ASSERT(realNumRegex.isValid());
253
254 QString output = input;
255 QRegularExpressionMatch realNumRegexMatch = realNumRegex.match(output);
256 while (realNumRegexMatch.hasMatch()) {
257 if (!realNumRegexMatch.captured(2).isNull()) {
258 (void) output.replace(realNumRegexMatch.capturedStart(2), realNumRegexMatch.capturedEnd(2) - realNumRegexMatch.capturedStart(2), QStringLiteral(" point "));
259 }
260 realNumRegexMatch = realNumRegex.match(output);
261 }
262
263 return output;
264}
265
266QString AudioOutput::_replaceMeters(const QString &input)
267{
268 static const QRegularExpression realNumMeterRegex(QStringLiteral("[0-9]*\\.?[0-9]\\s?(m)([^A-Za-z]|$)"));
269 Q_ASSERT(realNumMeterRegex.isValid());
270
271 QString output = input;
272 QRegularExpressionMatch realNumMeterRegexMatch = realNumMeterRegex.match(output);
273 while (realNumMeterRegexMatch.hasMatch()) {
274 if (!realNumMeterRegexMatch.captured(1).isNull()) {
275 (void) output.replace(realNumMeterRegexMatch.capturedStart(1), realNumMeterRegexMatch.capturedEnd(1) - realNumMeterRegexMatch.capturedStart(1), QStringLiteral(" meters"));
276 }
277 realNumMeterRegexMatch = realNumMeterRegex.match(output);
278 }
279
280 return output;
281}
282
283QString AudioOutput::_convertMilliseconds(const QString &input)
284{
285 QString result = input;
286
287 QString match;
288 int number;
289 if (_getMillisecondString(input, match, number) && (number >= 1000)) {
290 QString newNumber;
291 if (number < 60000) {
292 const int seconds = number / 1000;
293 const int ms = number - (seconds * 1000);
294 newNumber = QStringLiteral("%1 second%2").arg(seconds).arg(seconds > 1 ? "s" : "");
295 if (ms > 0) {
296 (void) newNumber.append(QStringLiteral(" and %1 millisecond").arg(ms));
297 }
298 } else {
299 const int minutes = number / 60000;
300 const int seconds = (number - (minutes * 60000)) / 1000;
301 newNumber = QStringLiteral("%1 minute%2").arg(minutes).arg(minutes > 1 ? "s" : "");
302 if (seconds > 0) {
303 (void) newNumber.append(QStringLiteral(" and %1 second%2").arg(seconds).arg(seconds > 1 ? "s" : ""));
304 }
305 }
306 (void) result.replace(match, newNumber);
307 }
308
309 return result;
310}
311
312bool AudioOutput::_getMillisecondString(const QString &string, QString &match, int &number)
313{
314 static const QRegularExpression msRegex("((?<number>[0-9]+)ms)");
315 Q_ASSERT(msRegex.isValid());
316
317 bool result = false;
318
319 QRegularExpressionMatch regexpMatch = msRegex.match(string);
320 if (regexpMatch.hasMatch()) {
321 match = regexpMatch.captured(0);
322 const QString numberStr = regexpMatch.captured("number");
323 number = numberStr.toInt();
324 result = true;
325 }
326
327 return result;
328}
Q_APPLICATION_STATIC(AudioOutput, _audioOutput)
QString errorString
#define QGC_LOGGING_CATEGORY(name, categoryStr)
The AudioOutput class provides functionality for audio output using text-to-speech.
Definition AudioOutput.h:12
void say(const QString &text, TextMods textMods=TextMod::None)
AudioOutput(QObject *parent=nullptr)
void testAudioOutput()
Tests the audio output. Will stop current output before test.
static AudioOutput * instance()
~AudioOutput()
Destructor for the AudioOutput class.
void init(Fact *volumeFact, Fact *mutedFact)
Initialize the Singleton.
A Fact is used to hold a single value within the system.
Definition Fact.h:17
QVariant rawValue() const
Value after translation.
Definition Fact.h:85
void valueChanged(const QVariant &value)
This signal is only meant for use by the QT property system. It should not be connected to by client ...
bool runningUnitTests()