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 <QtCore/QTimer>
9#include <QtTextToSpeech/QTextToSpeech>
10#include <QtTextToSpeech/QVoice>
11
12#include <algorithm>
13
14QGC_LOGGING_CATEGORY(AudioOutputLog, "Utilities.AudioOutput");
15// qt.speech.tts.flite
16// qt.speech.tts.android
17
18const QHash<QString, QString> AudioOutput::_textHash = {
19 { "ERR", "error" },
20 { "POSCTL", "Position Control" },
21 { "ALTCTL", "Altitude Control" },
22 { "AUTO_RTL", "auto return to launch" },
23 { "RTL", "return To launch" },
24 { "ACCEL", "accelerometer" },
25 { "RC_MAP_MODE_SW", "RC mode switch" },
26 { "REJ", "rejected" },
27 { "WP", "waypoint" },
28 { "CMD", "command" },
29 { "COMPID", "component eye dee" },
30 { "PARAMS", "parameters" },
31 { "ID", "I.D." },
32 { "ADSB", "A.D.S.B." },
33 { "EKF", "E.K.F." },
34 { "PREARM", "pre arm" },
35 { "PITOT", "pee toe" },
36 { "SERVOX_FUNCTION","Servo X Function" },
37};
38
40
42 : QObject(parent)
43 // Auto-select a real engine, except under unit tests where the "none" backend avoids probing
44 // system plugins (e.g. speechd) that emit critical load errors and trip the strict log check.
45 , _engine(QGC::runningUnitTests()
46 ? new QTextToSpeech(QStringLiteral("none"), this)
47 : new QTextToSpeech(this))
48{
49 // qCDebug(AudioOutputLog) << this;
50}
51
53{
54 // qCDebug(AudioOutputLog) << this;
55}
56
58{
59 return _audioOutput();
60}
61
62void AudioOutput::init(Fact* volumeFact, Fact* mutedFact)
63{
64 Q_CHECK_PTR(volumeFact);
65 Q_CHECK_PTR(mutedFact);
66
67 if (_initialized) {
68 return;
69 }
70
71 _volumeFact = volumeFact;
72 _mutedFact = mutedFact;
73
74 // Some QTextToSpeech backends (notably Android) initialize asynchronously, so finalize on Ready rather than bailing (Qt docs).
75 (void) connect(_engine, &QTextToSpeech::stateChanged, this, [this](QTextToSpeech::State state) {
76 if (state == QTextToSpeech::State::Ready) {
77 _textQueueSize = 0;
78 if (!_initialized) {
79 _finishInit();
80 }
81 }
82 qCDebug(AudioOutputLog) << "TTS State changed to:" << state;
83 });
84
85 (void) connect(_engine, &QTextToSpeech::errorOccurred, this, [this](QTextToSpeech::ErrorReason reason, const QString &errorString) {
86 qCWarning(AudioOutputLog) << "TTS error occurred. Reason:" << reason << ", Message:" << errorString;
87 _textQueueSize = 0;
88 });
89
90 // Decrement as each utterance leaves the queue so the counter tracks live backlog, not cumulative enqueues since drain.
91 (void) connect(_engine, &QTextToSpeech::aboutToSynthesize, this, [this](qsizetype) {
92 if (_textQueueSize > 0) {
93 _textQueueSize--;
94 }
95 });
96
97 (void) connect(_engine, &QTextToSpeech::engineChanged, this, [this](const QString &engine) {
98 qCDebug(AudioOutputLog) << "TTS Engine set to:" << engine;
99 _applyEngineSettings();
100 });
101
102 if (_engine->state() == QTextToSpeech::State::Ready) {
103 _finishInit();
104 } else {
105 // Some backends (notably Android) start in Error state while binding to the system TTS
106 // service, then transition to Ready. Defer and only warn if the engine never becomes usable.
107 qCDebug(AudioOutputLog) << "QTextToSpeech engine not ready; deferring init. State:" << _engine->state();
108 if (!QGC::runningUnitTests()) {
109 // Skip under unit tests: the "none" backend never becomes Ready and the warning would trip the strict log check.
110 QTimer::singleShot(kEngineInitWarnTimeout, this, [this]() {
111 if (!_initialized) {
112 qCWarning(AudioOutputLog) << "No usable QTextToSpeech engine available. Engine:" << _engine->engine()
113 << "State:" << _engine->state()
114 << "Reason:" << _engine->errorReason() << _engine->errorString();
115 }
116 });
117 }
118 }
119}
120
121void AudioOutput::_finishInit()
122{
123 _applyEngineSettings();
124
125 (void) connect(_volumeFact, &Fact::valueChanged, this, [this]() {
126 _setVolume();
127 });
128
129 (void) connect(_mutedFact, &Fact::valueChanged, this, [this]() {
130 _setVolume();
131 });
132
133 if (AudioOutputLog().isDebugEnabled()) {
134 (void) connect(_engine, &QTextToSpeech::localeChanged, this, [](const QLocale &locale) {
135 qCDebug(AudioOutputLog) << "TTS Locale change to:" << locale;
136 });
137 (void) connect(_engine, &QTextToSpeech::volumeChanged, this, [](double volume) {
138 qCDebug(AudioOutputLog) << "TTS Volume changed to:" << volume;
139 });
140 (void) connect(_engine, &QTextToSpeech::sayingWord, this, [](const QString &word, qsizetype id, qsizetype start, qsizetype length) {
141 qCDebug(AudioOutputLog) << "TTS Saying:" << word << "ID:" << id << "Start:" << start << "Length:" << length;
142 });
143 }
144
145 _initialized = true;
146 _setVolume();
147
148 qCDebug(AudioOutputLog) << "AudioOutput initialized with volume:" << _volumeSetting() << "%";
149}
150
151double AudioOutput::_volumeSetting() const
152{
153 return std::clamp(_volumeFact->rawValue().toDouble(), 0.0, 100.0);
154}
155
156bool AudioOutput::_mutedSetting() const
157{
158 return _mutedFact->rawValue().toBool();
159}
160
161void AudioOutput::_applyEngineSettings()
162{
163 if (_engine->state() != QTextToSpeech::State::Ready) {
164 return;
165 }
166
167 const QLocale defaultLocale("en_US");
168 if (_engine->availableLocales().contains(defaultLocale)) {
169 _engine->setLocale(defaultLocale);
170 }
171
172 // Pin an explicit voice so output doesn't depend on the engine's per-OS default.
173 const QList<QVoice> voices = _engine->availableVoices();
174 if (!voices.isEmpty()) {
175 _engine->setVoice(voices.constFirst());
176 }
177
178 _speakCapable = _engine->engineCapabilities().testFlag(QTextToSpeech::Capability::Speak);
179}
180
181void AudioOutput::_setVolume()
182{
183 const bool muted = _mutedSetting();
184 const double volume = muted ? 0.0 : _volumeSetting();
185
186 // qFuzzyCompare fails near zero; adding 1.0 shifts values into a safe range
187 if (qFuzzyCompare(1.0 + volume, 1.0 + _lastVolume)) {
188 return;
189 }
190 _lastVolume = volume;
191
192 // Must normalize volume to 0.0 - 1.0 for QTextToSpeech
193 const double normalizedVolume = volume / 100.0;
194 (void) QMetaObject::invokeMethod(_engine, [this, volume, normalizedVolume]() {
195 if (volume == 0.0) {
196 // Prevent any queued text from being spoken once muted
197 _engine->stop(QTextToSpeech::BoundaryHint::Immediate);
198 _textQueueSize = 0;
199 }
200 _engine->setVolume(normalizedVolume);
201 });
202 qCDebug(AudioOutputLog) << "AudioOutput volume set to:" << volume << "%";
203}
204
205void AudioOutput::say(const QString &text, TextMods textMods)
206{
207 if (!_initialized) {
208 if (!QGC::runningUnitTests()) {
209 qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using say().";
210 }
211 return;
212 }
213
214 if (_volumeSetting() <= 0.0 || _mutedSetting()) {
215 return;
216 }
217
218 if (!_speakCapable) {
219 qCWarning(AudioOutputLog) << "Speech Not Supported:" << text;
220 return;
221 }
222
223 QString outText = _fixTextMessageForAudio(text);
224
225 if (textMods.testFlag(TextMod::Translate)) {
226 outText = tr("%1").arg(outText);
227 }
228
229 if (outText.isEmpty()) {
230 return;
231 }
232
233 // All queue/counter mutation must stay on the engine thread (where stateChanged resets it).
234 (void) QMetaObject::invokeMethod(_engine, [this, outText]() {
235 if (_textQueueSize >= kMaxTextQueueSize) {
236 _engine->stop(QTextToSpeech::BoundaryHint::Immediate);
237 _textQueueSize = 0;
238 qCWarning(AudioOutputLog) << "Text queue exceeded maximum size. Stopped current speech.";
239 }
240
241 const qsizetype index = _engine->enqueue(outText);
242 if (index < 0) {
243 qCWarning(AudioOutputLog) << "Failed to enqueue speech. State:" << _engine->state()
244 << "Reason:" << _engine->errorReason();
245 return;
246 }
247
248 _textQueueSize++;
249 qCDebug(AudioOutputLog) << "Enqueued text with index:" << index << ", Queue Size:" << _textQueueSize;
250 });
251}
252
254{
255 if (!_initialized) {
256 qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using testAudioOutput().";
257 return;
258 }
259
260 // Main-thread only (QML-invoked): mutates the engine and counter directly without marshaling.
261 _engine->stop(QTextToSpeech::BoundaryHint::Immediate);
262 _textQueueSize = 0;
263
264 const QString testText = tr("Audio test. Volume is %1 percent").arg(_volumeSetting(), 0, 'f', 1);
265 say(testText);
266}
267
268QString AudioOutput::_fixTextMessageForAudio(const QString &string)
269{
270 QString result = string;
271 result = _replaceAbbreviations(result);
272 result = _replaceNegativeSigns(result);
273 result = _replaceDecimalPoints(result);
274 result = _replaceMeters(result);
275 result = _convertMilliseconds(result);
276 return result;
277}
278
279QString AudioOutput::_replaceAbbreviations(const QString &input)
280{
281 QStringList words = input.split(' ');
282 for (QString &word : words) {
283 const auto it = _textHash.constFind(word.toUpper());
284 if (it != _textHash.constEnd()) {
285 word = it.value();
286 }
287 }
288
289 return words.join(' ');
290}
291
292QString AudioOutput::_replaceNegativeSigns(const QString &input)
293{
294 static const QRegularExpression negNumRegex(QStringLiteral("-\\s*(?=\\d)"));
295 Q_ASSERT(negNumRegex.isValid());
296
297 QString output = input;
298 (void) output.replace(negNumRegex, "negative ");
299 return output;
300}
301
302QString AudioOutput::_replaceDecimalPoints(const QString &input)
303{
304 static const QRegularExpression realNumRegex(QStringLiteral("([0-9]+)(\\.)([0-9]+)"));
305 Q_ASSERT(realNumRegex.isValid());
306
307 QString output = input;
308 QRegularExpressionMatch realNumRegexMatch = realNumRegex.match(output);
309 while (realNumRegexMatch.hasMatch()) {
310 if (!realNumRegexMatch.captured(2).isNull()) {
311 (void) output.replace(realNumRegexMatch.capturedStart(2), realNumRegexMatch.capturedEnd(2) - realNumRegexMatch.capturedStart(2), QStringLiteral(" point "));
312 }
313 realNumRegexMatch = realNumRegex.match(output);
314 }
315
316 return output;
317}
318
319QString AudioOutput::_replaceMeters(const QString &input)
320{
321 static const QRegularExpression realNumMeterRegex(QStringLiteral("[0-9]*\\.?[0-9]\\s?(m)([^A-Za-z]|$)"));
322 Q_ASSERT(realNumMeterRegex.isValid());
323
324 QString output = input;
325 QRegularExpressionMatch realNumMeterRegexMatch = realNumMeterRegex.match(output);
326 while (realNumMeterRegexMatch.hasMatch()) {
327 if (!realNumMeterRegexMatch.captured(1).isNull()) {
328 (void) output.replace(realNumMeterRegexMatch.capturedStart(1), realNumMeterRegexMatch.capturedEnd(1) - realNumMeterRegexMatch.capturedStart(1), QStringLiteral(" meters"));
329 }
330 realNumMeterRegexMatch = realNumMeterRegex.match(output);
331 }
332
333 return output;
334}
335
336QString AudioOutput::_convertMilliseconds(const QString &input)
337{
338 QString result = input;
339
340 QString match;
341 int number;
342 if (_getMillisecondString(input, match, number) && (number >= 1000)) {
343 QString newNumber;
344 if (number < 60000) {
345 const int seconds = number / 1000;
346 const int ms = number - (seconds * 1000);
347 newNumber = QStringLiteral("%1 second%2").arg(seconds).arg(seconds > 1 ? "s" : "");
348 if (ms > 0) {
349 (void) newNumber.append(QStringLiteral(" and %1 millisecond").arg(ms));
350 }
351 } else {
352 const int minutes = number / 60000;
353 const int seconds = (number - (minutes * 60000)) / 1000;
354 newNumber = QStringLiteral("%1 minute%2").arg(minutes).arg(minutes > 1 ? "s" : "");
355 if (seconds > 0) {
356 (void) newNumber.append(QStringLiteral(" and %1 second%2").arg(seconds).arg(seconds > 1 ? "s" : ""));
357 }
358 }
359 (void) result.replace(match, newNumber);
360 }
361
362 return result;
363}
364
365bool AudioOutput::_getMillisecondString(const QString &string, QString &match, int &number)
366{
367 static const QRegularExpression msRegex("((?<number>[0-9]+)ms)");
368 Q_ASSERT(msRegex.isValid());
369
370 bool result = false;
371
372 QRegularExpressionMatch regexpMatch = msRegex.match(string);
373 if (regexpMatch.hasMatch()) {
374 match = regexpMatch.captured(0);
375 const QString numberStr = regexpMatch.captured("number");
376 number = numberStr.toInt();
377 result = true;
378 }
379
380 return result;
381}
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:14
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
Definition Fact.h:90
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()