Revision control
++ // Copyright (C) 2022 The Qt Company Ltd.
++ // SPDX-License-Identifier: LicenseRef-Qt-Commercial OR GPL-3.0-only
++
++
++ #include <QTest>
++ #include <QTextToSpeech>
++ #include <QSignalSpy>
++ #include <QMediaDevices>
++ #include <QAudioFormat>
++ #include <QAudioDevice>
++ #include <QAudioBuffer>
++ #include <QElapsedTimer>
++ #include <QOperatingSystemVersion>
++ #include <QRegularExpression>
++ #include <qttexttospeech-config.h>
++
++ #if QT_CONFIG(speechd)
++ #include <libspeechd.h>
++ #if LIBSPEECHD_MAJOR_VERSION == 0 && LIBSPEECHD_MINOR_VERSION < 9
++ #define HAVE_SPEECHD_BEFORE_090
++ #endif
++ #endif
++
++ using namespace Qt::StringLiterals;
++
++ enum : int { SpeechDuration = 20000 };
++
++ class tst_QTextToSpeech : public QObject
++ {
++ Q_OBJECT
++
++ private slots:
++ void initTestCase_data();
++ void init();
++
++ void capabilities();
++
++ void availableVoices();
++ void availableLocales();
++ void findVoices_data();
++ void findVoices();
++
++ void locale();
++ void voice();
++
++ void rate();
++ void pitch();
++ void volume();
++
++ void sayHello();
++ void pauseResume();
++ void sayWithVoices();
++ void sayWithRates();
++
++ void sayMultiple_data();
++ void sayMultiple();
++ void sayInvalid();
++
++ void pauseAtUtterance_data();
++ void pauseAtUtterance();
++
++ void sayingWord_data();
++ void sayingWord();
++
++ void sayingWordWithPause_data();
++ void sayingWordWithPause();
++
++ void synthesize_data();
++ void synthesize();
++
++ void synthesizeCallback_data();
++ void synthesizeCallback();
++
++ public:
++ using Selector = QList<QVoice>(*)(const QTextToSpeech *);
++ using VoiceData = typename std::tuple<QString, QLocale, QVoice::Gender, QVoice::Age>;
++
++ private:
++ static bool hasDefaultAudioOutput()
++ {
++ return !QMediaDevices::defaultAudioOutput().isNull();
++ }
++ static void selectWorkingVoice(QTextToSpeech *tts)
++ {
++ if (tts->engine() == "speechd") {
++ // The voices from the "mary-generic" modules are broken in
++ // normal installations. Default to one that works.
++ if (tts->voice().name().startsWith("dfki-")) {
++ for (const auto &voice : tts->availableVoices()) {
++ if (!voice.name().startsWith("dfki-")) {
++ qWarning() << "Replacing default voice" << tts->voice() << "with" << voice;
++ tts->setVoice(voice);
++ break;
++ }
++ }
++ }
++ }
++ }
++
++ void onError(QTextToSpeech::ErrorReason error, const QString &errorString) {
++ errorReason = error;
++ qCritical() << "Error:" << errorString;
++ }
++
++ QTextToSpeech::ErrorReason errorReason = QTextToSpeech::ErrorReason::NoError;
++ };
++
++ // enable QCOMPARE and debug output in case of failure
++ QT_BEGIN_NAMESPACE
++ bool operator==(const QList<QVoice> &lhs, const QList<tst_QTextToSpeech::VoiceData> &rhs)
++ {
++ if (lhs.size() != rhs.size())
++ return false;
++ // we can't assume any stable sorting, so we have to check that all
++ // entries from lhs are also in rhs.
++ for (const auto &voice : lhs) {
++ const tst_QTextToSpeech::VoiceData data{voice.name(), voice.locale(),
++ voice.gender(), voice.age()};
++ if (!rhs.contains(data))
++ return false;
++ }
++ return true;
++ }
++
++ QDebug &operator<<(QDebug &dbg, const tst_QTextToSpeech::VoiceData &data)
++ {
++ QDebugStateSaver rollback(dbg);
++ const auto [name, locale, gender, age] = data;
++ dbg.nospace().noquote();
++ dbg << "VoiceData(name: " << name
++ << ", locale: " << locale
++ << ", gender: " << QVoice::genderName(gender)
++ << ", age: " << QVoice::ageName(age)
++ << ")";
++ return dbg;
++ }
++ QT_END_NAMESPACE
++
++ void tst_QTextToSpeech::initTestCase_data()
++ {
++ qInfo("Available text-to-speech engines:");
++ QTest::addColumn<QString>("engine");
++ const auto engines = QTextToSpeech::availableEngines();
++ if (!engines.size())
++ QSKIP("No speech engines available, skipping test case");
++ for (const auto &engine : engines) {
++
++ QString engineName = engine;
++ #if QT_CONFIG(speechd) && defined(LIBSPEECHD_MAJOR_VERSION) && defined(LIBSPEECHD_MINOR_VERSION)
++ if (engine == "speechd") {
++ engineName += QString(" (using libspeechd %1.%2)").arg(LIBSPEECHD_MAJOR_VERSION)
++ .arg(LIBSPEECHD_MINOR_VERSION);
++ }
++ #ifdef HAVE_SPEECHD_BEFORE_090
++ engineName += " - libspeechd too old, skipping";
++ #endif
++ #endif
++ qInfo().noquote() << "- " << engineName;
++
++ #ifdef HAVE_SPEECHD_BEFORE_090
++ if (engine == "speechd")
++ continue;
++ #endif
++ QTest::addRow("%s", engine.toUtf8().constData()) << engine;
++ }
++ }
++
++ void tst_QTextToSpeech::init()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine == "speechd") {
++ QTextToSpeech tts(engine);
++ if (tts.state() == QTextToSpeech::Error) {
++ QSKIP("speechd engine reported an error, "
++ "make sure the speech-dispatcher service is running!");
++ }
++ } else if (engine == "darwin"
++ && QOperatingSystemVersion::current() <= QOperatingSystemVersion::MacOSMojave) {
++ QTextToSpeech tts(engine);
++ if (!tts.availableLocales().size())
++ QSKIP("iOS engine is not functional on macOS <= 10.14");
++ }
++ }
++
++ void tst_QTextToSpeech::capabilities()
++ {
++ QFETCH_GLOBAL(QString, engine);
++
++ QTextToSpeech tts(engine);
++ QVERIFY(tts.engineCapabilities() & QTextToSpeech::Capability::Speak);
++
++ // verify that the mock engine implements all capabilities
++ if (engine == "mock") {
++ const QMetaEnum capEnum = QMetaEnum::fromType<QTextToSpeech::Capabilities>();
++ QTextToSpeech::Capabilities mockCaps = QTextToSpeech::Capability::None;
++ for (int index = 0; index < capEnum.keyCount(); ++index) {
++ int value = capEnum.keyToValue(capEnum.key(index));
++ mockCaps |= QTextToSpeech::Capability(value);
++ }
++
++ QCOMPARE(tts.engineCapabilities(), mockCaps);
++ }
++ }
++
++ void tst_QTextToSpeech::availableVoices()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ qInfo("Available voices:");
++ const auto availableVoices = tts.availableVoices();
++ QVERIFY(availableVoices.size() > 0);
++ for (const auto &voice : availableVoices)
++ qInfo().noquote() << "-" << voice;
++ }
++
++ void tst_QTextToSpeech::availableLocales()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ const auto availableLocales = tts.availableLocales();
++ QVERIFY(availableLocales.size() > 0);
++ qInfo("Available locales:");
++ for (const auto &locale : availableLocales)
++ qInfo().noquote() << "-" << locale;
++ }
++
++ void tst_QTextToSpeech::findVoices_data()
++ {
++ #define SELECTOR(...) Selector([](const QTextToSpeech *tts) -> QList<QVoice> \
++ { return tts->findVoices(__VA_ARGS__); })
++
++ QTest::addColumn<QList<VoiceData>>("voicesData");
++ QTest::addColumn<Selector>("selector");
++ QTest::addColumn<QList<VoiceData>>("expectedVoices");
++
++ const VoiceData bob = {u"Bob"_s, QLocale(QLocale::English, QLocale::UnitedKingdom),
++ QVoice::Male, QVoice::Senior};
++ const VoiceData alice = {u"Alice"_s, QLocale(QLocale::English, QLocale::UnitedStates),
++ QVoice::Female, QVoice::Senior};
++ const VoiceData maleSenior = {u"Bob"_s, QLocale(QLocale::English),
++ QVoice::Male, QVoice::Senior};
++ const VoiceData maleChild = {u"Thomas"_s, QLocale(QLocale::German),
++ QVoice::Male, QVoice::Child};
++ const VoiceData femaleTeen = {u"Anne"_s, QLocale(QLocale::English, QLocale::Australia),
++ QVoice::Female, QVoice::Teenager};
++ const VoiceData femaleAdult = {u"Mary"_s, QLocale(QLocale::English),
++ QVoice::Female, QVoice::Adult};
++ const VoiceData diverseTeen = {u"Charly"_s, QLocale(QLocale::French),
++ QVoice::Unknown, QVoice::Teenager};
++ const VoiceData diverseAgeless = {u"Sam"_s, QLocale(QLocale::Chinese),
++ QVoice::Unknown, QVoice::Other};
++ const VoiceData fromOslo = {u"Julia"_s, QLocale(QLocale::NorwegianBokmal, QLocale::Norway),
++ QVoice::Female, QVoice::Adult};
++ const VoiceData fromWestcoast = {u"Morten"_s, QLocale(QLocale::NorwegianNynorsk, QLocale::Norway),
++ QVoice::Male, QVoice::Teenager};
++
++ const QList<VoiceData> allVoices{bob, alice,
++ maleSenior, maleChild,
++ femaleTeen, femaleAdult,
++ diverseTeen, diverseAgeless,
++ fromOslo, fromWestcoast};
++
++ QTest::addRow("one-of-one") << QList<VoiceData>{bob}
++ << SELECTOR(u"Bob"_s)
++ << QList<VoiceData>{bob};
++ QTest::addRow("none") << allVoices
++ << SELECTOR(u"Francis"_s)
++ << QList<VoiceData>{};
++ QTest::addRow("all") << QList<VoiceData>{bob, alice}
++ << SELECTOR()
++ << QList<VoiceData>{bob, alice};
++ QTest::addRow("bobs") << allVoices
++ << SELECTOR(u"Bob"_s)
++ << QList<VoiceData>{bob, maleSenior};
++ QTest::addRow("male") << allVoices
++ << SELECTOR(QVoice::Male)
++ << QList<VoiceData>{fromWestcoast, bob, maleSenior, maleChild};
++ QTest::addRow("senior") << allVoices
++ << SELECTOR(QVoice::Senior)
++ << QList<VoiceData>{bob, alice, maleSenior};
++ QTest::addRow("Chinese") << allVoices
++ << SELECTOR(QLocale::Chinese)
++ << QList<VoiceData>{diverseAgeless};
++ QTest::addRow("from Norway") << allVoices
++ << SELECTOR(QLocale::Norway)
++ << QList<VoiceData>{fromOslo, fromWestcoast};
++
++ // multiple of same type - not supported as it would always yield an empty result,
++ // so we generate a compile-time error. Ideally we could logically OR those criteria,
++ // but this is not a SQL database.
++ /*
++ QTest::addRow("impossible") << allVoices
++ << SELECTOR(QLocale::Norway, QLocale::Sweden)
++ << QList<VoiceData>{};
++ */
++
++ // mostly compile tests
++ QTest::addRow("QLatin1String") << allVoices
++ << SELECTOR(QLatin1String("Alice"))
++ << QList<VoiceData>{alice};
++ QTest::addRow("QLatin1StringView") << allVoices
++ << SELECTOR(QLatin1StringView("Alice"))
++ << QList<VoiceData>{alice};
++ QTest::addRow("QStringView") << allVoices
++ << SELECTOR(QStringView(u"Alice"))
++ << QList<VoiceData>{alice};
++ QTest::addRow("QRegularExpression") << allVoices
++ << SELECTOR(QRegularExpression("A(.*)"))
++ << QList<VoiceData>{alice, femaleTeen};
++
++ #ifndef QT_NO_CAST_FROM_ASCII
++ // compile tests for types that are implicitly convertible to QString
++ QTest::addRow("const char*") << allVoices
++ << SELECTOR("Alice")
++ << QList<VoiceData>{alice};
++ QTest::addRow("QByteArray") << allVoices
++ << SELECTOR(QByteArray("Alice"))
++ << QList<VoiceData>{alice};
++ #endif
++
++ #undef SELECTOR
++ }
++
++ void tst_QTextToSpeech::findVoices()
++ {
++ QFETCH_GLOBAL(const QString, engine);
++ // Testing once with mock engine is enough, no need to generate QSKIP noise
++ if (engine != "mock")
++ return;
++
++ QFETCH(const QList<VoiceData>, voicesData);
++ QFETCH(const Selector, selector);
++ QFETCH(const QList<VoiceData>, expectedVoices);
++ QVariantMap parameters;
++ parameters["voices"] = QVariant::fromValue(voicesData);
++ QTextToSpeech tts(engine, parameters);
++ QSignalSpy localeChangedSpy(&tts, &QTextToSpeech::localeChanged);
++ QSignalSpy voiceChangedSpy(&tts, &QTextToSpeech::voiceChanged);
++
++ const QList<QVoice> result = selector(&tts);
++
++ QCOMPARE(result, expectedVoices);
++ QCOMPARE(localeChangedSpy.count(), 0);
++ QCOMPARE(voiceChangedSpy.count(), 0);
++
++ // compile test
++ QCOMPARE(tts.findVoices(tts.locale()), tts.availableVoices());
++ }
++
++ /*
++ Testing the locale property, and its dependency on the voice
++ property.
++ */
++ void tst_QTextToSpeech::locale()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_VERIFY(tts.state() == QTextToSpeech::Ready);
++
++ const auto availableLocales = tts.availableLocales();
++ // every engine must have a working default locale if it's Ready
++ QVERIFY(availableLocales.contains(tts.locale()));
++ if (availableLocales.size() < 2)
++ QSKIP("Engine doesn't support more than one locale");
++
++ tts.setLocale(availableLocales[0]);
++ // changing the locale results in a voice that is supported by that locale
++ const auto voices0 = tts.availableVoices();
++ const QVoice voice0 = tts.voice();
++ QVERIFY(voices0.contains(voice0));
++
++ QSignalSpy localeSpy(&tts, &QTextToSpeech::localeChanged);
++ QSignalSpy voiceSpy(&tts, &QTextToSpeech::voiceChanged);
++
++ // repeat, watch signal emissions
++ tts.setLocale(availableLocales[1]);
++ QCOMPARE(localeSpy.size(), 1);
++
++ // a locale is only available if it has voices
++ const auto voices1 = tts.availableVoices();
++ QVERIFY(voices1.size());
++ // If the voice is supported in the new locale, then it shouldn't change,
++ // otherwise the voice should change as well.
++ if (voices1.contains(voice0))
++ QCOMPARE(voiceSpy.size(), 0);
++ else
++ QCOMPARE(voiceSpy.size(), 1);
++ }
++
++ /*
++ Testing the voice property, and its dependency on the locale
++ property.
++
++ We cannot test all things on engines that have only a single voice, or no
++ locale that supports multiple voices.
++ */
++ void tst_QTextToSpeech::voice()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_VERIFY(tts.state() == QTextToSpeech::Ready);
++
++ const QVoice defaultVoice = tts.voice();
++ const QLocale defaultLocale = tts.locale();
++ // every engine must have a working default voice if it's Ready
++ QVERIFY(defaultVoice != QVoice());
++
++ // find a locale with more than one voice, and a voice from another locale
++ QList<QVoice> availableVoices;
++ QLocale voicesLocale;
++ QVoice otherVoice;
++ QLocale otherLocale;
++ for (const auto &locale : tts.availableLocales()) {
++ tts.setLocale(locale);
++ const auto voices = tts.availableVoices();
++ if (voices.size() > 1 && availableVoices.isEmpty()) {
++ availableVoices = voices;
++ voicesLocale = locale;
++ }
++ if (voices != availableVoices && voices.first() != defaultVoice) {
++ otherVoice = voices.first();
++ otherLocale = locale;
++ }
++ // we found everything
++ if (availableVoices.size() > 1 && otherVoice != QVoice())
++ break;
++ }
++ // if we found neither, then we cannot test
++ if (availableVoices.size() < 2 && otherVoice == QVoice())
++ QSKIP("Engine %s supports only a single voice", qPrintable(engine));
++
++ tts.setVoice(defaultVoice);
++ QCOMPARE(tts.locale(), defaultLocale);
++
++ int expectedVoiceChanged = 0;
++ int expectedLocaleChanged = 0;
++ QSignalSpy voiceSpy(&tts, &QTextToSpeech::voiceChanged);
++ QSignalSpy localeSpy(&tts, &QTextToSpeech::localeChanged);
++
++ if (otherVoice != QVoice() && otherVoice != defaultVoice) {
++ QVERIFY(otherLocale != voicesLocale);
++ // at this point, setting to otherVoice only changes things when it's not the default
++ ++expectedVoiceChanged;
++ ++expectedLocaleChanged;
++ tts.setVoice(otherVoice);
++ QCOMPARE(voiceSpy.size(), expectedVoiceChanged);
++ QCOMPARE(tts.locale(), otherLocale);
++ QCOMPARE(localeSpy.size(), expectedLocaleChanged);
++ } else {
++ otherLocale = defaultLocale;
++ }
++
++ // two voices from the same locale
++ if (availableVoices.size() > 1) {
++ if (tts.voice() != availableVoices[0])
++ ++expectedVoiceChanged;
++ tts.setVoice(availableVoices[0]);
++ QCOMPARE(voiceSpy.size(), expectedVoiceChanged);
++ if (voicesLocale != otherLocale)
++ ++expectedLocaleChanged;
++ QCOMPARE(localeSpy.size(), expectedLocaleChanged);
++ tts.setVoice(availableVoices[1]);
++ QCOMPARE(voiceSpy.size(), ++expectedVoiceChanged);
++ QCOMPARE(localeSpy.size(), expectedLocaleChanged);
++ }
++ }
++
++ void tst_QTextToSpeech::rate()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++
++ tts.setRate(0.5);
++ QCOMPARE(tts.rate(), 0.5);
++ QSignalSpy spy(&tts, &QTextToSpeech::rateChanged);
++ tts.setRate(0.0);
++ QCOMPARE(spy.size(), 1);
++ QCOMPARE(spy.value(0).first().toDouble(), 0.0);
++
++ tts.setRate(tts.rate());
++ QCOMPARE(spy.size(), 1);
++ }
++
++ void tst_QTextToSpeech::pitch()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ tts.setPitch(0.0);
++ QCOMPARE(tts.pitch(), 0.0);
++
++ QSignalSpy spy(&tts, &QTextToSpeech::pitchChanged);
++ int signalCount = 0;
++ for (int i = -10; i <= 10; ++i) {
++ tts.setPitch(i / 10.0);
++ QString actual = QString("%1").arg(tts.pitch(), 0, 'g', 2);
++ QString expected = QString("%1").arg(i / 10.0, 0, 'g', 2);
++ QCOMPARE(actual, expected);
++ QCOMPARE(spy.size(), ++signalCount);
++ tts.setPitch(tts.pitch());
++ QCOMPARE(spy.size(), signalCount);
++ }
++ }
++
++ void tst_QTextToSpeech::volume()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ tts.setVolume(0.0);
++
++ QSignalSpy spy(&tts, &QTextToSpeech::volumeChanged);
++ tts.setVolume(0.7);
++ QTRY_COMPARE(spy.size(), 1);
++ QVERIFY(spy.value(0).first().toDouble() > 0.6);
++
++ QCOMPARE_GT(tts.volume(), 0.65);
++ QCOMPARE_LT(tts.volume(), 0.75);
++
++ tts.setVolume(tts.volume());
++ QCOMPARE(spy.size(), 1);
++ }
++
++
++ void tst_QTextToSpeech::sayHello()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ const QString text = QStringLiteral("saying hello with %1");
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++ auto logger = qScopeGuard([&tts]{
++ qWarning() << "Failure with voice" << tts.voice();
++ });
++
++ QElapsedTimer timer;
++ timer.start();
++ tts.say(text.arg(engine));
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ QSignalSpy spy(&tts, &QTextToSpeech::stateChanged);
++ QVERIFY(spy.wait(SpeechDuration));
++ QCOMPARE(tts.state(), QTextToSpeech::Ready);
++ QVERIFY(timer.elapsed() > 100);
++ logger.dismiss();
++ }
++
++ void tst_QTextToSpeech::pauseResume()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++ if (engine == "speechd")
++ QSKIP("Native speech engine is faulty");
++
++ const QString text = QStringLiteral("Hello. World.");
++ QTextToSpeech tts(engine);
++
++ if (!(tts.engineCapabilities() & QTextToSpeech::Capability::PauseResume))
++ QSKIP("This engine doesn't support PauseResume");
++
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++
++ tts.say(text);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ // tts engines will either pause immediately, or in a suitable break,
++ // which would be after "Hello."
++ tts.pause();
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Paused);
++ tts.resume();
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ }
++
++ void tst_QTextToSpeech::sayWithVoices()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ const QString text = QStringLiteral("engine %1 with voice of %2");
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++
++ const QList<QVoice> voices = tts.availableVoices();
++ for (qsizetype i = 0; i < voices.size(); ++i) {
++
++ if (i > 9) {
++ qWarning() << "sayWithVoices ended after 10 out of" << voices.size() << "voices.";
++ break;
++ }
++
++ const auto voice = voices.at(i);
++ if (engine == "speechd" && voice.name().startsWith("dfki-")) {
++ qWarning() << "Voice dysfunctional:" << voice;
++ continue;
++ }
++ tts.setVoice(voice);
++ auto logger = qScopeGuard([&voice]{
++ qWarning() << "Failure with voice" << voice;
++ });
++ QCOMPARE(tts.state(), QTextToSpeech::Ready);
++
++ QElapsedTimer timer;
++ timer.start();
++ qDebug() << text.arg(engine, voice.name());
++ tts.say(text.arg(engine, voice.name()));
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ QSignalSpy spy(&tts, &QTextToSpeech::stateChanged);
++ QVERIFY(spy.wait(SpeechDuration));
++ QCOMPARE(tts.state(), QTextToSpeech::Ready);
++ QVERIFY(timer.elapsed() > 100);
++ logger.dismiss();
++ }
++ }
++
++ void tst_QTextToSpeech::sayWithRates()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ const QString text = QStringLiteral("test at different rates");
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++ auto logger = qScopeGuard([&tts]{
++ qWarning() << "Failure with voice" << tts.voice();
++ });
++
++ // warmup at normal rate so that the result doesn't get skewed on engines
++ // that initialize lazily on first utterance (like Android).
++ tts.say(text);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++
++ QElapsedTimer timer;
++ qint64 time = 0;
++ connect(&tts, &QTextToSpeech::stateChanged, this, [&](QTextToSpeech::State state){
++ if (state == QTextToSpeech::Speaking)
++ timer.start();
++ else if (state == QTextToSpeech::Ready)
++ time = timer.elapsed();
++ });
++
++ qint64 lastTime = 0;
++ // check that speaking at slower rate takes more time (for 0.5, 0.0, -0.5)
++ for (int i = 1; i >= -1; --i) {
++ time = 0;
++ tts.setRate(i * 0.5);
++ tts.say(text);
++ QTRY_VERIFY2(time > lastTime, qPrintable(QString("%1 took %2, last was %3"_L1)
++ .arg(i).arg(time).arg(lastTime)));
++ lastTime = time;
++ }
++ logger.dismiss();
++ }
++
++ void tst_QTextToSpeech::sayInvalid()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++ auto logger = qScopeGuard([&tts]{
++ qWarning() << "Failure with voice" << tts.voice();
++ });
++
++ int speakingCount = 0;
++ bool doneSpeaking = false;
++ connect(&tts, &QTextToSpeech::stateChanged, this, [&](QTextToSpeech::State state){
++ if (state == QTextToSpeech::Speaking)
++ ++speakingCount;
++ else if (state == QTextToSpeech::Ready)
++ doneSpeaking = true;
++ });
++
++ tts.say("©");
++ QTRY_VERIFY(doneSpeaking);
++ QTRY_COMPARE(speakingCount, 1);
++ }
++
++ void tst_QTextToSpeech::sayMultiple_data()
++ {
++ QTest::addColumn<QStringList>("textList");
++
++ QTest::addRow("one") << QStringList{"one"};
++ QTest::addRow("three") << QStringList{"one", "two", "three"};
++ }
++
++ void tst_QTextToSpeech::sayMultiple()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++ auto logger = qScopeGuard([&tts]{
++ qWarning() << "Failure with voice" << tts.voice();
++ });
++
++ int speakingCount = 0;
++ bool doneSpeaking = false;
++ connect(&tts, &QTextToSpeech::stateChanged, this, [&](QTextToSpeech::State state){
++ if (state == QTextToSpeech::Speaking)
++ ++speakingCount;
++ else if (state == QTextToSpeech::Ready)
++ doneSpeaking = true;
++ });
++ QSignalSpy aboutToSynthesizeSpy(&tts, &QTextToSpeech::aboutToSynthesize);
++
++ QFETCH(const QStringList, textList);
++ for (qsizetype i = 0; i < textList.count(); ++i) {
++ const QString &text = textList.at(i);
++ qDebug() << "Equeued utterance" << tts.enqueue(text);
++ if (!i) // wait for the engine to start synthesizing
++ QTRY_COMPARE_NE(tts.state(), QTextToSpeech::Ready);
++ }
++
++ QTRY_VERIFY(doneSpeaking);
++ QCOMPARE(aboutToSynthesizeSpy.count(), textList.size());
++ QCOMPARE(speakingCount, 1);
++
++ qDebug() << "Synthesized utterances" << aboutToSynthesizeSpy;
++ logger.dismiss();
++ }
++
++ void tst_QTextToSpeech::pauseAtUtterance_data()
++ {
++ sayMultiple_data();
++ }
++
++ void tst_QTextToSpeech::pauseAtUtterance()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ QFETCH(const QStringList, textList);
++
++ QTextToSpeech tts(engine);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++
++ int atIndex = -1;
++ bool paused = false;
++ connect(&tts, &QTextToSpeech::aboutToSynthesize, this, [&]{
++ ++atIndex;
++ if (atIndex == 1 && !paused) {
++ tts.pause(QTextToSpeech::BoundaryHint::Utterance);
++ paused = true;
++ --atIndex;
++ }
++ });
++ QStringList wordsSpoken;
++ connect(&tts, &QTextToSpeech::sayingWord, this,
++ [&](const QString &word, qsizetype id, qsizetype at, qsizetype length){
++ const QString text = textList.at(atIndex).mid(at, length);
++ QCOMPARE(id, atIndex);
++ QCOMPARE(text, word);
++ wordsSpoken += text;
++ });
++
++ for (qsizetype i = 0; i < textList.count(); ++i) {
++ const QString &text = textList.at(i);
++ QCOMPARE(tts.enqueue(text), i);
++ if (!i)
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ }
++ if (textList.count() == 1)
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ else
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Paused);
++ if (tts.engineCapabilities() & QTextToSpeech::Capability::WordByWordProgress)
++ QCOMPARE(wordsSpoken, QStringList{textList.first()});
++ tts.resume();
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ if (tts.engineCapabilities() & QTextToSpeech::Capability::WordByWordProgress)
++ QCOMPARE(wordsSpoken, textList);
++ else
++ qInfo("Skipping test of spoken words");
++ }
++
++ void tst_QTextToSpeech::sayingWord_data()
++ {
++ QTest::addColumn<QString>("text");
++
++ QTest::addRow("one word") << "supercalifragilisticexpialidocious";
++ QTest::addRow("sentence") << "this is one word.";
++ QTest::addRow("punctuation") << "this, if you want: a word!";
++ QTest::addRow("two sentences") << "First word. Second word.";
++ }
++
++ void tst_QTextToSpeech::sayingWord()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ QFETCH(QString, text);
++
++ const QStringList expectedWords = text.split(QRegularExpression("\\W"), Qt::SkipEmptyParts);
++
++ QTextToSpeech tts(engine);
++ if (!(tts.engineCapabilities() & QTextToSpeech::Capability::WordByWordProgress))
++ QSKIP("This engine doesn't support word-by-word progress");
++
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++
++ QElapsedTimer timer;
++ QStringList words;
++
++ QList<std::chrono::nanoseconds> times;
++ connect(&tts, &QTextToSpeech::sayingWord, this,
++ [&words, ×, &timer, text](const QString &word, qsizetype id, qsizetype start, qsizetype length) {
++ const QString &slice = text.sliced(start, length);
++ QCOMPARE(word, slice);
++ QCOMPARE(id, 0);
++ words << text.sliced(start, length);
++ times << timer.durationElapsed();
++ });
++
++ timer.start();
++ tts.say(text);
++ auto debugHelper = qScopeGuard([&]{
++ qWarning() << "Recorded words:" << words;
++ qWarning() << "Expected words:" << expectedWords;
++ });
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Speaking);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ auto totalTime = timer.durationElapsed();
++
++ QCOMPARE(words, expectedWords);
++
++ // Makes sure that the last word is reported late. Engines need to warm up,
++ // and some test data has a "slow" word at the end, but empirically,
++ // 40% into the total time is reliable and still makes sure that the signal
++ // doesn't get emitted with all words immediately.
++ if (words.count() > 1)
++ QCOMPARE_GE(times.last(), totalTime * 0.4);
++
++ std::list<std::chrono::nanoseconds> timeDifferences;
++ std::adjacent_difference(times.begin(), times.end(), std::back_inserter(timeDifferences));
++ timeDifferences.pop_front(); // first element is always 0
++
++ for (auto timeDiff : timeDifferences)
++ QCOMPARE_GT(timeDiff, std::chrono::milliseconds(5));
++
++ debugHelper.dismiss();
++ }
++
++ void tst_QTextToSpeech::sayingWordWithPause_data()
++ {
++ QTest::addColumn<QStringList>("words");
++ QTest::addColumn<int>("pauseAt");
++
++ const QStringList words{"this", "is", "a", "sentence", "with", "words"};
++ QTest::addRow("pause1") << words << 1;
++ QTest::addRow("pause4") << words << 4;
++ }
++
++ void tst_QTextToSpeech::sayingWordWithPause()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock" && !hasDefaultAudioOutput())
++ QSKIP("No audio device present");
++
++ QFETCH(QStringList, words);
++ QFETCH(int, pauseAt);
++
++ const QString text = words.join(u' ');
++
++ QTextToSpeech tts(engine);
++
++ if (!(tts.engineCapabilities() & QTextToSpeech::Capability::WordByWordProgress))
++ QSKIP("This engine doesn't support word-by-word progress");
++
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++
++ QStringList spokenWords;
++ connect(&tts, &QTextToSpeech::sayingWord, this,
++ [&](const QString &word, qsizetype id, qsizetype start, qsizetype length) {
++ QCOMPARE(word, text.sliced(start, length));
++ QCOMPARE(id, 0);
++ spokenWords << text.sliced(start, length);
++ if (spokenWords.size() == pauseAt)
++ tts.pause(QTextToSpeech::BoundaryHint::Word);
++ });
++
++ auto debugHelper = qScopeGuard([&]{
++ qWarning() << "Spoken words:" << spokenWords;
++ });
++
++ tts.say(text);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Paused);
++
++ // the engine might still signal us about the next word
++ QCOMPARE_LE(spokenWords.size(), words.size());
++ // wait and verify that no more words are reported
++ QTest::qWait(500);
++ QCOMPARE_LE(spokenWords.size(), pauseAt + 1);
++
++ // Resume, and make sure that all words are reported.
++ // We might get some words reported twice, depending on how
++ // the engine supports word bounaries when pausing, and how
++ // much of the text it repeats when resuming.
++ tts.resume();
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ QTRY_COMPARE_GE(spokenWords.size(), words.size());
++ for (const auto &word : words)
++ QVERIFY(spokenWords.contains(word));
++
++ debugHelper.dismiss();
++ }
++
++ void tst_QTextToSpeech::synthesize_data()
++ {
++ QTest::addColumn<QString>("text");
++
++ QTest::addRow("text") << "Let's synthesize some text!";
++ }
++
++ void tst_QTextToSpeech::synthesize()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ QFETCH(QString, text);
++
++ const bool canCheckDuration = engine == "mock" || hasDefaultAudioOutput();
++
++ QTextToSpeech tts(engine);
++ if (!(tts.engineCapabilities() & QTextToSpeech::Capability::Synthesize))
++ QSKIP("This engine doesn't support synthesize()");
++
++ connect(&tts, &QTextToSpeech::errorOccurred, this, &tst_QTextToSpeech::onError);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ selectWorkingVoice(&tts);
++
++ QElapsedTimer speechTimer;
++ // We can't assume that synthesis isn't done before we can check, and that we only
++ // have a single change during an event loop cycle, so connect to the signal
++ // and keep track ourselves.
++ bool running = false;
++ bool finished = false;
++ qint64 speechTime = 0;
++ connect(&tts, &QTextToSpeech::stateChanged, this,
++ [&running, &finished, &speechTimer, &speechTime](QTextToSpeech::State state) {
++ if (state == QTextToSpeech::Synthesizing || state == QTextToSpeech::Speaking) {
++ speechTimer.start();
++ running = true;
++ finished = false;
++ }
++ if (running && state == QTextToSpeech::Ready) {
++ if (!speechTime)
++ speechTime = speechTimer.elapsed();
++ finished = true;
++ }
++ });
++
++ // warm up to avoid that initializing audio hardware makes timing flaky
++ if (canCheckDuration) {
++ tts.say("Initializing");
++ QTRY_VERIFY(running);
++ QTRY_VERIFY(finished);
++ running = false;
++ finished = false;
++ speechTimer.invalidate();
++ speechTime = 0;
++ }
++
++ // first, measure how long it takes to speak the text. We can't do that if we
++ // can't play audio.
++ if (canCheckDuration) {
++ tts.say(text);
++ QTRY_VERIFY(running);
++ QTRY_VERIFY(finished);
++ }
++
++ running = false;
++ QAudioFormat pcmFormat;
++ QByteArray pcmData;
++
++ QElapsedTimer notBlockingTimer;
++ notBlockingTimer.start();
++ tts.synthesize(text, [&pcmFormat, &pcmData](const QAudioFormat &format, const QByteArray &bytes) {
++ pcmFormat = format;
++ pcmData += bytes;
++ });
++ QCOMPARE_LT(notBlockingTimer.elapsed(), 250);
++ QTRY_VERIFY(running);
++ QTRY_VERIFY(finished);
++
++ QVERIFY(pcmFormat.isValid());
++ QVERIFY(!pcmData.isEmpty());
++
++ if (canCheckDuration) {
++ // bytesForDuration takes micro seconds, we measured in milliseconds.
++ const qint32 bytesExpected = pcmFormat.bytesForDuration(speechTime * 1000);
++
++ // We should have as much data as the format requires for the time it took
++ // to play the speech, +/- 15% as we can't measure the exact audio duration.
++ QCOMPARE_GE(pcmData.size(), double(bytesExpected) * 0.85);
++ if (engine == "flite") // flite is very unreliable
++ QCOMPARE_LT(pcmData.size(), double(bytesExpected) * 1.5);
++ else
++ QCOMPARE_LT(pcmData.size(), double(bytesExpected) * 1.1);
++ }
++ }
++
++ /*!
++ API test for the functor variants of synthesize(), using only the mock
++ engine as the engine implementation is identical to the non-functor
++ version tested above.
++ */
++ void tst_QTextToSpeech::synthesizeCallback_data()
++ {
++ QTest::addColumn<QString>("text");
++
++ QTest::addRow("one") << "test";
++ QTest::addRow("several") << "this will produce more than one chunk.";
++ }
++
++ void tst_QTextToSpeech::synthesizeCallback()
++ {
++ QFETCH_GLOBAL(QString, engine);
++ if (engine != "mock")
++ QSKIP("Only testing with mock engine");
++
++ QTextToSpeech tts(engine);
++ QVERIFY(tts.engineCapabilities() & QTextToSpeech::Capability::Synthesize);
++
++ QFETCH(QString, text);
++
++ QAudioFormat expectedFormat;
++ QByteArray expectedBytes;
++
++ // record a reference using the already tested version
++ tts.synthesize(text,[&expectedFormat, &expectedBytes]
++ (const QAudioFormat &format, const QByteArray &bytes) {
++ expectedFormat = format;
++ expectedBytes += bytes;
++ });
++ QTRY_VERIFY(expectedFormat.isValid());
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++
++ struct Processor : QObject {
++ void process(const QAudioFormat &format, const QByteArray &bytes)
++ {
++ m_format = format;
++ m_allBytes += bytes;
++ }
++ void audioFormatKnown(const QAudioFormat &format)
++ {
++ m_format = format;
++ }
++ void reset()
++ {
++ m_format = {};
++ m_allBytes = {};
++ }
++ QAudioFormat m_format;
++ QByteArray m_allBytes;
++ } processor;
++
++ // Functor without context
++ tts.synthesize(text, [&processor](const QAudioFormat &format, const QByteArray &bytes){
++ processor.m_format = format;
++ processor.m_allBytes += bytes;
++ });
++ QTRY_COMPARE(processor.m_format, expectedFormat);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ QCOMPARE(processor.m_allBytes, expectedBytes);
++ processor.reset();
++ // Functor with context
++ tts.synthesize(text, &tts, [&processor](const QAudioFormat &format, const QByteArray &bytes){
++ processor.m_format = format;
++ processor.m_allBytes += bytes;
++ });
++ QTRY_COMPARE(processor.m_format, expectedFormat);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ QCOMPARE(processor.m_allBytes, expectedBytes);
++ processor.reset();
++ // Partial functor
++ tts.synthesize(text, [&processor](const QAudioFormat &format){
++ processor.m_format = format;
++ });
++ QTRY_COMPARE(processor.m_format, expectedFormat);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ processor.reset();
++ // PMF
++ tts.synthesize(text, &processor, &Processor::process);
++ QTRY_COMPARE(processor.m_format, expectedFormat);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ QCOMPARE(processor.m_allBytes, expectedBytes);
++ processor.reset();
++ // PMF with no QByteArray argument - not very useful, but Qt allows it
++ tts.synthesize(text, &processor, &Processor::audioFormatKnown);
++ QTRY_COMPARE(processor.m_format, expectedFormat);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ QCOMPARE(processor.m_allBytes, QByteArray());
++ processor.reset();
++
++ // Taking QAudioBuffer
++ tts.synthesize(text, [&processor](const QAudioBuffer &buffer) {
++ processor.m_format = buffer.format();
++ processor.m_allBytes += QByteArrayView(buffer.data<uchar>(), buffer.byteCount());
++ });
++ QTRY_COMPARE(processor.m_format, expectedFormat);
++ QTRY_COMPARE(tts.state(), QTextToSpeech::Ready);
++ QCOMPARE(processor.m_allBytes, expectedBytes);
++ processor.reset();
++ }
++
++ QTEST_MAIN(tst_QTextToSpeech)
++ #include "tst_qtexttospeech.moc"