Skip to content

Commit

Permalink
AudioOutput: Fix queue size and improve thread safety
Browse files Browse the repository at this point in the history
  • Loading branch information
HTRamsey committed Nov 23, 2024
1 parent 9cf3dde commit cca0cb6
Show file tree
Hide file tree
Showing 4 changed files with 206 additions and 115 deletions.
230 changes: 155 additions & 75 deletions src/Audio/AudioOutput.cc
Original file line number Diff line number Diff line change
Expand Up @@ -47,33 +47,6 @@ AudioOutput::AudioOutput(QObject *parent)
, _engine(new QTextToSpeech(QStringLiteral("none"), this))
{
// qCDebug(AudioOutputLog) << Q_FUNC_INFO << this;

if (!QTextToSpeech::availableEngines().isEmpty()) {
if (_engine->setEngine(QString())) {
// Autoselect engine by priority
qCDebug(AudioOutputLog) << Q_FUNC_INFO << "engine:" << _engine->engine();
if (_engine->availableLocales().contains(QLocale("en_US"))) {
_engine->setLocale(QLocale("en_US"));
}

(void) connect(this, &AudioOutput::mutedChanged, [this](bool muted) {
qCDebug(AudioOutputLog) << Q_FUNC_INFO << "muted:" << muted;
(void) QMetaObject::invokeMethod(_engine, "setVolume", Qt::AutoConnection, muted ? 0. : 1.);
});
}
}

#ifdef QT_DEBUG
(void) connect(_engine, &QTextToSpeech::stateChanged, [](QTextToSpeech::State state) {
qCDebug(AudioOutputLog) << Q_FUNC_INFO << "State:" << state;
});
(void) connect(_engine, &QTextToSpeech::errorOccurred, [](QTextToSpeech::ErrorReason reason, const QString &errorString) {
qCDebug(AudioOutputLog) << Q_FUNC_INFO << "Error: (" << reason << ") " << errorString;
});
(void) connect(_engine, &QTextToSpeech::volumeChanged, [](double volume) {
qCDebug(AudioOutputLog) << Q_FUNC_INFO << "volume:" << volume;
});
#endif
}

AudioOutput::~AudioOutput()
Expand All @@ -90,115 +63,222 @@ void AudioOutput::init(Fact *mutedFact)
{
Q_CHECK_PTR(mutedFact);

if (_initialized) {
return;
}

if (QTextToSpeech::availableEngines().isEmpty()) {
qCWarning(AudioOutputLog) << "No available QTextToSpeech engines found.";
return;
}

// Autoselect engine by priority
if (!_engine->setEngine(QString())) {
qCWarning(AudioOutputLog) << "Failed to set the TTS engine.";
return;
}

(void) connect(_engine, &QTextToSpeech::engineChanged, [this](const QString &engine) {
qCDebug(AudioOutputLog) << "TTS Engine set to:" << engine;
const QLocale defaultLocale = QLocale("en_US");
if (_engine->availableLocales().contains(defaultLocale)) {
_engine->setLocale(defaultLocale);
}
});

(void) connect(_engine, &QTextToSpeech::aboutToSynthesize, [this](qsizetype id) {
qCDebug(AudioOutputLog) << "TTS About To Synthesize ID:" << id;
_textQueueSize--;
qCDebug(AudioOutputLog) << "Queue Size:" << _textQueueSize;
});

(void) connect(mutedFact, &Fact::valueChanged, this, [this](QVariant value) {
setMuted(value.toBool());
});

if (AudioOutputLog().isDebugEnabled()) {
(void) connect(_engine, &QTextToSpeech::stateChanged, [this](QTextToSpeech::State state) {
qCDebug(AudioOutputLog) << "TTS State changed to:" << state;
});
(void) connect(_engine, &QTextToSpeech::errorOccurred, [](QTextToSpeech::ErrorReason reason, const QString &errorString) {
qCDebug(AudioOutputLog) << "TTS Error occurred. Reason:" << reason << ", Message:" << errorString;
});
(void) connect(_engine, &QTextToSpeech::localeChanged, [](const QLocale &locale) {
qCDebug(AudioOutputLog) << "TTS Locale change to:" << locale;
});
(void) connect(_engine, &QTextToSpeech::volumeChanged, [](double volume) {
qCDebug(AudioOutputLog) << "TTS Volume changed to:" << volume;
});
(void) connect(_engine, &QTextToSpeech::sayingWord, [](const QString &word, qsizetype id, qsizetype start, qsizetype length) {
qCDebug(AudioOutputLog) << "TTS Saying:" << word << "ID:" << id << "Start:" << start << "Length:" << length;
});
}

setMuted(mutedFact->rawValue().toBool());
_initialized = true;

qCDebug(AudioOutputLog) << "AudioOutput initialized with muted state:" << _muted;
}

void AudioOutput::say(const QString &text, AudioOutput::TextMods textMods)
void AudioOutput::setMuted(bool muted)
{
if (_muted.exchange(muted) != muted) {
(void) QMetaObject::invokeMethod(_engine, "setVolume", Qt::AutoConnection, muted ? 0.0 : 1.0);
qCDebug(AudioOutputLog) << "AudioOutput muted state set to:" << muted;
}
}

void AudioOutput::say(const QString &text, TextMods textMods)
{
if (!_initialized) {
qCWarning(AudioOutputLog) << "AudioOutput not initialized. Call init() before using say().";
return;
}

if (_muted) {
return;
}

if (!_engine->engineCapabilities().testFlag(QTextToSpeech::Capability::Speak)) {
qCWarning(AudioOutputLog) << Q_FUNC_INFO << "Speech Not Supported:" << text;
qCWarning(AudioOutputLog) << "Speech Not Supported:" << text;
return;
}

if (_textQueueSize > kMaxTextQueueSize) {
if (_textQueueSize >= kMaxTextQueueSize) {
(void) QMetaObject::invokeMethod(_engine, "stop", Qt::AutoConnection, QTextToSpeech::BoundaryHint::Default);
_textQueueSize = 0;
qCWarning(AudioOutputLog) << "Text queue exceeded maximum size. Stopped current speech.";
}

QString outText = AudioOutput::fixTextMessageForAudio(text);
QString outText = _fixTextMessageForAudio(text);

if (textMods.testFlag(AudioOutput::TextMod::Translate)) {
outText = QCoreApplication::translate("AudioOutput", outText.toStdString().c_str());
if (textMods.testFlag(TextMod::Translate)) {
outText = tr("%1").arg(outText);
}

qsizetype index = -1;
(void) QMetaObject::invokeMethod(_engine, "enqueue", Qt::AutoConnection, qReturnArg(index), outText);
if (index != -1) {
_textQueueSize = index;
qsizetype index;
if (QMetaObject::invokeMethod(_engine, "enqueue", Qt::AutoConnection, qReturnArg(index), outText)) {
if (index != -1) {
_textQueueSize++;
qCDebug(AudioOutputLog) << "Enqueued text with index:" << index << ", Queue Size:" << _textQueueSize;
}
} else {
qCWarning(AudioOutputLog) << "Failed to invoke Enqueue method.";
}
}

bool AudioOutput::getMillisecondString(const QString &string, QString &match, int &number)
QString AudioOutput::_fixTextMessageForAudio(const QString &string)
{
static const QRegularExpression regexp("([0-9]+ms)");
QString result = string;
result = _replaceAbbreviations(result);
result = _replaceNegativeSigns(result);
result = _replaceDecimalPoints(result);
result = _replaceMeters(result);
result = _convertMilliseconds(result);
return result;
}

bool result = false;
QString AudioOutput::_replaceAbbreviations(const QString &input)
{
QString output = input;

for (const QRegularExpressionMatch &tempMatch : regexp.globalMatch(string)) {
if (tempMatch.hasMatch()) {
match = tempMatch.captured(0);
number = tempMatch.captured(0).replace("ms", "").toInt();
result = true;
break;
const QStringList wordList = input.split(' ', Qt::SkipEmptyParts);
for (const QString &word : wordList) {
const QString upperWord = word.toUpper();
if (_textHash.contains(upperWord)) {
(void) output.replace(word, _textHash.value(upperWord));
}
}

return result;
return output;
}

QString AudioOutput::fixTextMessageForAudio(const QString &string)
QString AudioOutput::_replaceNegativeSigns(const QString &input)
{
QString result = string;
static const QRegularExpression negNumRegex(QStringLiteral("-\\s*(?=\\d)"));
Q_ASSERT(negNumRegex.isValid());

for (const QString &word: string.split(' ', Qt::SkipEmptyParts)) {
if (_textHash.contains(word.toUpper())) {
result.replace(word, _textHash.value(word.toUpper()));
}
}

// Convert negative numbers
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), QStringLiteral(" negative "));
}
negNumRegexMatch = negNumRegex.match(result);
}
QString output = input;
(void) output.replace(negNumRegex, "negative ");
return output;
}

// Convert real number with decimal point
QString AudioOutput::_replaceDecimalPoints(const QString &input)
{
static const QRegularExpression realNumRegex(QStringLiteral("([0-9]+)(\\.)([0-9]+)"));
QRegularExpressionMatch realNumRegexMatch = realNumRegex.match(result);
Q_ASSERT(realNumRegex.isValid());

QString output = input;
QRegularExpressionMatch realNumRegexMatch = realNumRegex.match(output);
while (realNumRegexMatch.hasMatch()) {
if (!realNumRegexMatch.captured(2).isNull()) {
result.replace(realNumRegexMatch.capturedStart(2), realNumRegexMatch.capturedEnd(2) - realNumRegexMatch.capturedStart(2), QStringLiteral(" point "));
(void) output.replace(realNumRegexMatch.capturedStart(2), realNumRegexMatch.capturedEnd(2) - realNumRegexMatch.capturedStart(2), QStringLiteral(" point "));
}
realNumRegexMatch = realNumRegex.match(result);
realNumRegexMatch = realNumRegex.match(output);
}

// Convert meter postfix after real number
return output;
}

QString AudioOutput::_replaceMeters(const QString &input)
{
static const QRegularExpression realNumMeterRegex(QStringLiteral("[0-9]*\\.?[0-9]\\s?(m)([^A-Za-z]|$)"));
QRegularExpressionMatch realNumMeterRegexMatch = realNumMeterRegex.match(result);
Q_ASSERT(realNumMeterRegex.isValid());

QString output = input;
QRegularExpressionMatch realNumMeterRegexMatch = realNumMeterRegex.match(output);
while (realNumMeterRegexMatch.hasMatch()) {
if (!realNumMeterRegexMatch.captured(1).isNull()) {
result.replace(realNumMeterRegexMatch.capturedStart(1), realNumMeterRegexMatch.capturedEnd(1) - realNumMeterRegexMatch.capturedStart(1), QStringLiteral(" meters"));
(void) output.replace(realNumMeterRegexMatch.capturedStart(1), realNumMeterRegexMatch.capturedEnd(1) - realNumMeterRegexMatch.capturedStart(1), QStringLiteral(" meters"));
}
realNumMeterRegexMatch = realNumMeterRegex.match(result);
realNumMeterRegexMatch = realNumMeterRegex.match(output);
}

return output;
}

QString AudioOutput::_convertMilliseconds(const QString &input)
{
QString result = input;

QString match;
int number;
if (getMillisecondString(string, match, number) && (number > 1000)) {
if (_getMillisecondString(input, match, number) && (number >= 1000)) {
QString newNumber;
if (number < 60000) {
const int seconds = number / 1000;
const int ms = number - (seconds * 1000);
newNumber = QStringLiteral("%1 second%2").arg(seconds).arg(seconds > 1 ? "s" : "");
if (ms > 0) {
(void) newNumber.append(QStringLiteral(" and %1 millisecond").arg(ms));
}
} else {
const int minutes = number / 60000;
const int seconds = (number - (minutes * 60000)) / 1000;
newNumber = QStringLiteral("%1 minute%2").arg(minutes).arg(minutes > 1 ? "s" : "");
if (seconds) {
if (seconds > 0) {
(void) newNumber.append(QStringLiteral(" and %1 second%2").arg(seconds).arg(seconds > 1 ? "s" : ""));
}
}
result.replace(match, newNumber);
(void) result.replace(match, newNumber);
}

return result;
}

bool AudioOutput::_getMillisecondString(const QString &string, QString &match, int &number)
{
static const QRegularExpression msRegex("((?<number>[0-9]+)ms)");
Q_ASSERT(msRegex.isValid());

bool result = false;

QRegularExpressionMatch regexpMatch = msRegex.match(string);
if (regexpMatch.hasMatch()) {
match = regexpMatch.captured(0);
const QString numberStr = regexpMatch.captured("number");
number = numberStr.toInt();
result = true;
}

return result;
Expand Down
55 changes: 34 additions & 21 deletions src/Audio/AudioOutput.h
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@

class QTextToSpeech;
class Fact;
class AudioOutputTest;

Q_DECLARE_LOGGING_CATEGORY(AudioOutputLog)

Expand All @@ -22,7 +23,7 @@ class AudioOutput : public QObject
{
Q_OBJECT

Q_PROPERTY(bool muted READ isMuted WRITE setMuted NOTIFY mutedChanged)
friend AudioOutputTest;

public:
/// Enumeration for text modification options.
Expand Down Expand Up @@ -53,39 +54,51 @@ class AudioOutput : public QObject

/// Sets the mute state of the audio output.
/// @param enable True to mute, false to unmute.
void setMuted(bool muted) { if (muted != _muted) { _muted = muted; emit mutedChanged(_muted); } }
void setMuted(bool muted);

/// Reads the specified text with optional text modifications.
/// @param text The text to be read.
/// @param textMods The text modifications to apply.
void say(const QString &text, AudioOutput::TextMods textMods = TextMod::None);
void say(const QString &text, TextMods textMods = TextMod::None);

/// Extracts a millisecond value from the given string.
/// @param string The string to extract from.
/// @param match The extracted millisecond string.
/// @param number The extracted number.
/// @return True if extraction is successful, false otherwise.
static bool getMillisecondString(const QString &string, QString &match, int &number);
private:
QTextToSpeech *_engine = nullptr;
QAtomicInteger<qsizetype> _textQueueSize = 0;
bool _initialized = false;
std::atomic_bool _muted = false;

static const QHash<QString, QString> _textHash;

static constexpr qsizetype kMaxTextQueueSize = 20;

/// Fixes text messages for audio output.
/// @param string The text message to fix.
/// @return The fixed text message.
static QString fixTextMessageForAudio(const QString &string);
static QString _fixTextMessageForAudio(const QString &string);

signals:
/// Emitted when the mute state changes.
/// @param muted The new mute state.
void mutedChanged(bool muted);
/// Replaces predefined abbreviations with their corresponding full forms.
/// @param input The input string containing abbreviations.
/// @return A string with abbreviations replaced by their full forms.
static QString _replaceAbbreviations(const QString &input);

private:
QTextToSpeech *_engine = nullptr;
qsizetype _textQueueSize = 0;
bool _muted = false;
Fact *_mutedFact = nullptr;
/// Replaces negative signs with the word "negative".
static QString _replaceNegativeSigns(const QString &input);

static const QHash<QString, QString> _textHash;
/// Replaces decimal points with the word "point".
static QString _replaceDecimalPoints(const QString &input);

static constexpr qsizetype kMaxTextQueueSize = 20;
/// Replaces "m" (meters) with the word "meters" following numbers.
static QString _replaceMeters(const QString &input);

/// Converts millisecond values to a more readable format (seconds and minutes).
static QString _convertMilliseconds(const QString &input);

/// Extracts a millisecond value from the given string.
/// @param string The string to extract from.
/// @param match The extracted millisecond string.
/// @param number The extracted number.
/// @return True if extraction is successful, false otherwise.
static bool _getMillisecondString(const QString &string, QString &match, int &number);

};
Q_DECLARE_OPERATORS_FOR_FLAGS(AudioOutput::TextMods)
Loading

0 comments on commit cca0cb6

Please sign in to comment.