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
18// Keys in both tables must not end in a digit: trailing instance digits are split off before lookup.
19const QHash<QString, QString> AudioOutput::_textHash = {
20 {"ERR", "error"},
21 {"POSCTL", "Position Control"},
22 {"ALTCTL", "Altitude Control"},
23 {"AUTO_RTL", "auto return to launch"},
24 {"RTL", "return To launch"},
25 {"ACCEL", "accelerometer"},
26 {"RC_MAP_MODE_SW", "RC mode switch"},
27 {"REJ", "rejected"},
28 {"WP", "waypoint"},
29 {"CMD", "command"},
30 {"COMPID", "component eye dee"},
31 {"PARAMS", "parameters"},
32 {"ID", "I.D."},
33 {"ADSB", "A.D.S.B."},
34 {"EKF", "E.K.F."},
35 {"PREARM", "pre arm"},
36 {"PITOT", "pee toe"},
37 {"SERVOX_FUNCTION", "Servo X Function"},
38 {"CNT", "count"},
39 {"DNST", "density"},
40 {"TKOFF", "takeoff"},
41 {"TERRN", "terrain"},
42 {"AROT", "autorotation"},
43 {"TCAL", "temperature calibration"},
44 {"PWR", "power"},
45 {"PERF", "performance"},
46 {"CFG", "config"},
47 {"FBWA", "fly-by-wire A"},
48};
49
50// Matched case-sensitively so lowercase words ("rc", "ins") are left alone. CAN is omitted: it is spoken as a word.
51const QSet<QString> AudioOutput::_spelledAcronyms = {
52 "GPS", "GNSS", "GCS", "ESC", "VTOL", "RC", "IMU", "AHRS", "INS", "CPU", "RPM", "SD", "USB",
53 "PWM", "PPS", "CRC", "ICAO", "SIH", "HAGL", "FMU", "UTM", "MAV", "FFT", "AFS", "AMSL", "MSL",
54 "HDOP", "GPIO", "SITL", "ODID", "TCP", "UDP", "UART", "I2C", "IOMCU", "FTP", "NACK", "NED", "RSSI",
55 "OSD", "MSP", "DMA", "DFU", "DNA", "EFI", "ISR", "WDG", "NMEA", "RTCM", "VTX", "WMM", "HW",
56 "SW", "FW", "RX", "LLH", "IP", "IO", "RF", "LQ", "DEVID",
57};
58
60
62 : QObject(parent)
63 // Auto-select a real engine, except under unit tests where the "none" backend avoids probing
64 // system plugins (e.g. speechd) that emit critical load errors and trip the strict log check.
65 , _engine(QGC::runningUnitTests()
66 ? new QTextToSpeech(QStringLiteral("none"), this)
67 : new QTextToSpeech(this))
68{
69 // qCDebug(AudioOutputLog) << this;
70}
71
73{
74 // qCDebug(AudioOutputLog) << this;
75}
76
78{
79 return _audioOutput();
80}
81
82void AudioOutput::init(Fact* volumeFact, Fact* mutedFact)
83{
84 Q_CHECK_PTR(volumeFact);
85 Q_CHECK_PTR(mutedFact);
86
87 if (_initialized) {
88 return;
89 }
90
91 _volumeFact = volumeFact;
92 _mutedFact = mutedFact;
93
94 // Some QTextToSpeech backends (notably Android) initialize asynchronously, so finalize on Ready rather than bailing (Qt docs).
95 (void) connect(_engine, &QTextToSpeech::stateChanged, this, [this](QTextToSpeech::State state) {
96 if (state == QTextToSpeech::State::Ready) {
97 _textQueueSize = 0;
98 if (!_initialized) {
99 _finishInit();
100 }
101 }
102 qCDebug(AudioOutputLog) << "TTS State changed to:" << state;
103 });
104
105 (void) connect(_engine, &QTextToSpeech::errorOccurred, this, [this](QTextToSpeech::ErrorReason reason, const QString &errorString) {
106 qCWarning(AudioOutputLog) << "TTS error occurred. Reason:" << reason << ", Message:" << errorString;
107 _textQueueSize = 0;
108 });
109
110 // Decrement as each utterance leaves the queue so the counter tracks live backlog, not cumulative enqueues since drain.
111 (void) connect(_engine, &QTextToSpeech::aboutToSynthesize, this, [this](qsizetype) {
112 if (_textQueueSize > 0) {
113 _textQueueSize--;
114 }
115 });
116
117 (void) connect(_engine, &QTextToSpeech::engineChanged, this, [this](const QString &engine) {
118 qCDebug(AudioOutputLog) << "TTS Engine set to:" << engine;
119 _applyEngineSettings();
120 });
121
122 if (_engine->state() == QTextToSpeech::State::Ready) {
123 _finishInit();
124 } else {
125 // Some backends (notably Android) start in Error state while binding to the system TTS
126 // service, then transition to Ready. Defer and only warn if the engine never becomes usable.
127 qCDebug(AudioOutputLog) << "QTextToSpeech engine not ready; deferring init. State:" << _engine->state();
128 if (!QGC::runningUnitTests()) {
129 // Skip under unit tests: the "none" backend never becomes Ready and the warning would trip the strict log check.
130 QTimer::singleShot(kEngineInitWarnTimeout, this, [this]() {
131 if (!_initialized) {
132 qCWarning(AudioOutputLog) << "No usable QTextToSpeech engine available. Engine:" << _engine->engine()
133 << "State:" << _engine->state()
134 << "Reason:" << _engine->errorReason() << _engine->errorString();
135 }
136 });
137 }
138 }
139}
140
141void AudioOutput::_finishInit()
142{
143 _applyEngineSettings();
144
145 (void) connect(_volumeFact, &Fact::valueChanged, this, [this]() {
146 _setVolume();
147 });
148
149 (void) connect(_mutedFact, &Fact::valueChanged, this, [this]() {
150 _setVolume();
151 });
152
153 if (AudioOutputLog().isDebugEnabled()) {
154 (void) connect(_engine, &QTextToSpeech::localeChanged, this, [](const QLocale &locale) {
155 qCDebug(AudioOutputLog) << "TTS Locale change to:" << locale;
156 });
157 (void) connect(_engine, &QTextToSpeech::volumeChanged, this, [](double volume) {
158 qCDebug(AudioOutputLog) << "TTS Volume changed to:" << volume;
159 });
160 (void) connect(_engine, &QTextToSpeech::sayingWord, this, [](const QString &word, qsizetype id, qsizetype start, qsizetype length) {
161 qCDebug(AudioOutputLog) << "TTS Saying:" << word << "ID:" << id << "Start:" << start << "Length:" << length;
162 });
163 }
164
165 _initialized = true;
166 _setVolume();
167
168 qCDebug(AudioOutputLog) << "AudioOutput initialized with volume:" << _volumeSetting() << "%";
169}
170
171double AudioOutput::_volumeSetting() const
172{
173 return std::clamp(_volumeFact->rawValue().toDouble(), 0.0, 100.0);
174}
175
176bool AudioOutput::_mutedSetting() const
177{
178 return _mutedFact->rawValue().toBool();
179}
180
181void AudioOutput::_applyEngineSettings()
182{
183 if (_engine->state() != QTextToSpeech::State::Ready) {
184 return;
185 }
186
187 const QLocale defaultLocale("en_US");
188 if (_engine->availableLocales().contains(defaultLocale)) {
189 _engine->setLocale(defaultLocale);
190 }
191
192 // Pin an explicit voice so output doesn't depend on the engine's per-OS default.
193 const QList<QVoice> voices = _engine->availableVoices();
194 if (!voices.isEmpty()) {
195 _engine->setVoice(voices.constFirst());
196 }
197
198 _speakCapable = _engine->engineCapabilities().testFlag(QTextToSpeech::Capability::Speak);
199}
200
201void AudioOutput::_setVolume()
202{
203 const bool muted = _mutedSetting();
204 const double volume = muted ? 0.0 : _volumeSetting();
205
206 // qFuzzyCompare fails near zero; adding 1.0 shifts values into a safe range
207 if (qFuzzyCompare(1.0 + volume, 1.0 + _lastVolume)) {
208 return;
209 }
210 _lastVolume = volume;
211
212 // Must normalize volume to 0.0 - 1.0 for QTextToSpeech
213 const double normalizedVolume = volume / 100.0;
214 (void) QMetaObject::invokeMethod(_engine, [this, volume, normalizedVolume]() {
215 if (volume == 0.0) {
216 // Prevent any queued text from being spoken once muted
217 _engine->stop(QTextToSpeech::BoundaryHint::Immediate);
218 _textQueueSize = 0;
219 }
220 _engine->setVolume(normalizedVolume);
221 });
222 qCDebug(AudioOutputLog) << "AudioOutput volume set to:" << volume << "%";
223}
224
225void AudioOutput::say(const QString &text, TextMods textMods)
226{
227 if (!_initialized) {
228 if (!QGC::runningUnitTests()) {
229 qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using say().";
230 }
231 return;
232 }
233
234 if (_volumeSetting() <= 0.0 || _mutedSetting()) {
235 return;
236 }
237
238 if (!_speakCapable) {
239 qCWarning(AudioOutputLog) << "Speech Not Supported:" << text;
240 return;
241 }
242
243 QString outText = _fixTextMessageForAudio(text);
244
245 if (textMods.testFlag(TextMod::Translate)) {
246 outText = tr("%1").arg(outText);
247 }
248
249 if (outText.isEmpty()) {
250 return;
251 }
252
253 // All queue/counter mutation must stay on the engine thread (where stateChanged resets it).
254 (void) QMetaObject::invokeMethod(_engine, [this, outText]() {
255 if (_textQueueSize >= kMaxTextQueueSize) {
256 _engine->stop(QTextToSpeech::BoundaryHint::Immediate);
257 _textQueueSize = 0;
258 qCWarning(AudioOutputLog) << "Text queue exceeded maximum size. Stopped current speech.";
259 }
260
261 const qsizetype index = _engine->enqueue(outText);
262 if (index < 0) {
263 qCWarning(AudioOutputLog) << "Failed to enqueue speech. State:" << _engine->state()
264 << "Reason:" << _engine->errorReason();
265 return;
266 }
267
268 _textQueueSize++;
269 qCDebug(AudioOutputLog) << "Enqueued text with index:" << index << ", Queue Size:" << _textQueueSize;
270 });
271}
272
274{
275 if (!_initialized) {
276 qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using testAudioOutput().";
277 return;
278 }
279
280 // Main-thread only (QML-invoked): mutates the engine and counter directly without marshaling.
281 _engine->stop(QTextToSpeech::BoundaryHint::Immediate);
282 _textQueueSize = 0;
283
284 const QString testText = tr("Audio test. Volume is %1 percent").arg(_volumeSetting(), 0, 'f', 1);
285 say(testText);
286}
287
288QString AudioOutput::_fixTextMessageForAudio(const QString &string)
289{
290 QString result = string;
291 // Before abbreviations: spelled acronyms end in '.', which would hide the word a hyphen is attached to.
292 result = _replaceNegativeSigns(result);
293 result = _replaceAbbreviations(result);
294 result = _replaceDecimalPoints(result);
295 result = _replaceMeters(result);
296 result = _convertMilliseconds(result);
297 return result;
298}
299
300QString AudioOutput::_spokenAbbreviation(const QString& token)
301{
302 const auto it = _textHash.constFind(token.toUpper());
303 if (it != _textHash.constEnd()) {
304 return it.value();
305 }
306
307 if (!_spelledAcronyms.contains(token)) {
308 return QString();
309 }
310
311 QString spelled;
312 spelled.reserve(token.size() * 2);
313 for (const QChar c : token) {
314 spelled.append(c);
315 spelled.append(QLatin1Char('.'));
316 }
317 return spelled;
318}
319
320QString AudioOutput::_replaceAbbreviations(const QString &input)
321{
322 // Captures a whole identifier-like token, splitting off trailing instance digits ("EKF3" -> "EKF", "3").
323 static const QRegularExpression tokenRegex(
324 QStringLiteral("(?<![A-Za-z0-9_])([A-Za-z][A-Za-z0-9_]*?)([0-9]*)(?![A-Za-z0-9_])"));
325
326 QString output;
327 output.reserve(input.size());
328 qsizetype last = 0;
329
330 QRegularExpressionMatchIterator it = tokenRegex.globalMatch(input);
331 while (it.hasNext()) {
332 const QRegularExpressionMatch match = it.next();
333 const QString spoken = _spokenAbbreviation(match.captured(1));
334 if (spoken.isEmpty()) {
335 continue;
336 }
337
338 output.append(QStringView(input).mid(last, match.capturedStart() - last));
339 output.append(spoken);
340 if (match.capturedLength(2) > 0) {
341 output.append(QLatin1Char(' '));
342 output.append(match.capturedView(2));
343 }
344 last = match.capturedEnd();
345 }
346 output.append(QStringView(input).mid(last));
347
348 return output;
349}
350
351QString AudioOutput::_replaceNegativeSigns(const QString &input)
352{
353 static const QRegularExpression negNumRegex(QStringLiteral("(?<![A-Za-z0-9_])-\\s*(?=\\d)"));
354
355 QString output = input;
356 (void) output.replace(negNumRegex, "negative ");
357 return output;
358}
359
360QString AudioOutput::_replaceDecimalPoints(const QString &input)
361{
362 static const QRegularExpression realNumRegex(QStringLiteral("([0-9]+)(\\.)([0-9]+)"));
363
364 QString output = input;
365 QRegularExpressionMatch realNumRegexMatch = realNumRegex.match(output);
366 while (realNumRegexMatch.hasMatch()) {
367 if (!realNumRegexMatch.captured(2).isNull()) {
368 (void) output.replace(realNumRegexMatch.capturedStart(2), realNumRegexMatch.capturedEnd(2) - realNumRegexMatch.capturedStart(2), QStringLiteral(" point "));
369 }
370 realNumRegexMatch = realNumRegex.match(output);
371 }
372
373 return output;
374}
375
376QString AudioOutput::_replaceMeters(const QString &input)
377{
378 static const QRegularExpression realNumMeterRegex(QStringLiteral("[0-9]*\\.?[0-9]\\s?(m)([^A-Za-z]|$)"));
379
380 QString output = input;
381 QRegularExpressionMatch realNumMeterRegexMatch = realNumMeterRegex.match(output);
382 while (realNumMeterRegexMatch.hasMatch()) {
383 if (!realNumMeterRegexMatch.captured(1).isNull()) {
384 (void) output.replace(realNumMeterRegexMatch.capturedStart(1), realNumMeterRegexMatch.capturedEnd(1) - realNumMeterRegexMatch.capturedStart(1), QStringLiteral(" meters"));
385 }
386 realNumMeterRegexMatch = realNumMeterRegex.match(output);
387 }
388
389 return output;
390}
391
392QString AudioOutput::_convertMilliseconds(const QString &input)
393{
394 QString result = input;
395
396 QString match;
397 int number;
398 if (_getMillisecondString(input, match, number) && (number >= 1000)) {
399 QString newNumber;
400 if (number < 60000) {
401 const int seconds = number / 1000;
402 const int ms = number - (seconds * 1000);
403 newNumber = QStringLiteral("%1 second%2").arg(seconds).arg(seconds > 1 ? "s" : "");
404 if (ms > 0) {
405 (void) newNumber.append(QStringLiteral(" and %1 millisecond").arg(ms));
406 }
407 } else {
408 const int minutes = number / 60000;
409 const int seconds = (number - (minutes * 60000)) / 1000;
410 newNumber = QStringLiteral("%1 minute%2").arg(minutes).arg(minutes > 1 ? "s" : "");
411 if (seconds > 0) {
412 (void) newNumber.append(QStringLiteral(" and %1 second%2").arg(seconds).arg(seconds > 1 ? "s" : ""));
413 }
414 }
415 (void) result.replace(match, newNumber);
416 }
417
418 return result;
419}
420
421bool AudioOutput::_getMillisecondString(const QString &string, QString &match, int &number)
422{
423 static const QRegularExpression msRegex("((?<number>[0-9]+)ms)");
424
425 bool result = false;
426
427 QRegularExpressionMatch regexpMatch = msRegex.match(string);
428 if (regexpMatch.hasMatch()) {
429 match = regexpMatch.captured(0);
430 const QString numberStr = regexpMatch.captured("number");
431 number = numberStr.toInt();
432 result = true;
433 }
434
435 return result;
436}
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:17
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:15
QVariant rawValue() const
Definition Fact.h:87
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 ...
std::optional< T > number(std::string_view field, int base=DECIMAL_BASE)
bool runningUnitTests()