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